diff --git a/mcp/saas-status-mcp/.gitignore b/mcp/saas-status-mcp/.gitignore new file mode 100644 index 0000000..42cc921 --- /dev/null +++ b/mcp/saas-status-mcp/.gitignore @@ -0,0 +1,36 @@ +# CDK output +cdk.out* + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local + +# Generated by deploy-all — contains account-specific runtime ARN +local-proxy/mcp.json + +# Build artifacts +build/ + +# Terraform state and generated files +infrastructure/terraform/.terraform/ +infrastructure/terraform/.terraform.lock.hcl +infrastructure/terraform/terraform.tfvars +infrastructure/terraform/terraform.tfstate +infrastructure/terraform/terraform.tfstate.backup diff --git a/mcp/saas-status-mcp/ARCHITECTURE.md b/mcp/saas-status-mcp/ARCHITECTURE.md new file mode 100644 index 0000000..9c99369 --- /dev/null +++ b/mcp/saas-status-mcp/ARCHITECTURE.md @@ -0,0 +1,122 @@ +# Architecture — SaaS Status MCP Server + +## Overview + +This MCP server bridges AWS DevOps Agent's internal investigation capabilities with the external SaaS health signals that live outside AWS. It runs as a stateless Python server hosted on Amazon Bedrock AgentCore Runtime, exposing four MCP tools that DevOps Agent can call mid-investigation to correlate infrastructure signals with upstream dependency status. + +--- + +## High-Level Architecture + +``` +┌──────────────────┐ ┌─────────────────────────┐ ┌────────────────────┐ +│ AWS DevOps │ MCP │ AgentCore Runtime │ HTTPS │ Statuspage.io │ +│ Agent │───────>│ (saas-status-mcp) │───────>│ Public APIs │ +│ (Investigation) │ │ │ │ (no auth needed) │ +└──────────────────┘ └─────────────────────────┘ └────────────────────┘ + │ + │ Conditional GET (ETag) + ▼ + ┌─────────────────────┐ + │ S3: providers.json │ + │ (live registry) │ + └─────────────────────┘ +``` + +### Request flow + +1. DevOps Agent is investigating an alert and decides to check upstream dependencies. +2. It invokes the MCP server via the `bedrock-agentcore:InvokeAgentRuntime` API, signing with SigV4. +3. AgentCore Runtime routes the call to the MCP server process over `streamable-http` on port 8000. +4. The server fans out concurrent HTTPS requests to the relevant Statuspage.io public API endpoints. +5. Results are normalized and returned as structured JSON to DevOps Agent. + +--- + +## Components + +### AgentCore Runtime + +The hosting layer. AgentCore Runtime manages the container lifecycle, IAM authentication, and the MCP protocol transport so the server code has no AWS SDK calls in the hot path — it only does outbound HTTP. + +- **Transport**: `streamable-http` (stateless, required by AgentCore Runtime) +- **Network mode**: `PUBLIC` — the runtime makes outbound calls to public Statuspage.io endpoints; no VPC needed +- **Entrypoint**: `main.py` via `FastMCP` +- **Runtime environment**: Python 3.13 + +### MCP Server (`agent/`) + +| File | Responsibility | +|------|---------------| +| `main.py` | FastMCP app definition; declares the four `@mcp.tool` functions; binds to `0.0.0.0:8000` | +| `tools.py` | Tool implementations; `check_all_dependencies` fans requests out with `asyncio.gather` | +| `statuspage_client.py` | Async HTTP client (`httpx`) for the Statuspage.io `/api/v2/*` contract | +| `config.py` | S3-backed provider registry with ETag-based conditional GET — avoids reloading unless the file changes | +| `providers.json` | Source-controlled seed registry (28 providers); uploaded to S3 on first deploy | + +### Provider Registry (S3) + +The registry is a JSON array of `{name, display_name, statuspage_url}` objects. It is stored in S3 at `s3://saas-status-mcp--/config/providers.json` and read by the server at startup and then re-checked every 60 seconds via a conditional GET (using the `ETag` and `If-None-Match` headers). If the object has not changed, S3 returns a `304 Not Modified` and the server keeps its cached copy — zero read cost on steady state. + +This design allows operators to update the live provider list by pushing a new `providers.json` to S3 (via `refresh-providers.ps1/.sh`) without touching code or redeploying. + +### IAM + +| Role | Principal | Permissions | +|------|-----------|-------------| +| `SaasStatusMcpRuntimeRole` | `bedrock-agentcore.amazonaws.com` | `s3:GetObject` on deployment bucket; `logs:PutLogEvents` on `/aws/bedrock-agentcore/runtimes/*` | +| SigV4 signing role (registration stack) | `aidevops.amazonaws.com` | `bedrock-agentcore:InvokeAgentRuntime` on the runtime ARN | + +DevOps Agent assumes the signing role when invoking the runtime. The runtime itself assumes the runtime role to read from S3 and write logs. + +### CloudWatch Logs + +Structured JSON logs from the server are written to `/aws/bedrock-agentcore/runtimes/*` with a 14-day retention policy. The log group is torn down on stack destroy (`RemovalPolicy.DESTROY`). + +--- + +## CDK Stacks + +| Stack | Deployed to | Purpose | +|-------|-------------|---------| +| `SaasStatusMcpStack-{region}` | Runtime region | AgentCore Runtime, runtime IAM role, CloudWatch log group | +| `SaasStatusMcpRegistrationStack-{space-region}` | Agent Space region | SigV4 signing role, DevOps Agent Service, Association | + +The registration stack is optional and only deployed when you run `setup-devops-agent`. The two stacks can target different regions — the runtime ARN is exported from the main stack and imported by the registration stack. + +--- + +## Design Decisions + +### Stateless by design + +There is no database and no persisted state. Every tool call is a fresh read from Statuspage.io. This keeps the server simple, eliminates stale-data bugs, and makes horizontal scaling trivial — AgentCore Runtime can spin up multiple instances without coordination. + +### Single Statuspage.io client covers 80%+ of providers + +Most major SaaS vendors (Snowflake, Datadog, GitHub, MongoDB, PagerDuty, etc.) run on Atlassian Statuspage.io, which exposes a uniform public REST API at `/api/v2/status.json`, `/api/v2/incidents/unresolved.json`, and `/api/v2/scheduled-maintenances/active.json`. One generic client handles all of them — no provider-specific code, and adding a new provider is a JSON entry in the registry with no code change. + +### Parallel fan-out in `check_all_dependencies` + +`asyncio.gather` is used to fire all provider requests concurrently. For a 10-provider bulk check, wall-clock time is the max of individual response times rather than their sum — typically under 2 seconds. + +### ETag-based config caching + +The provider registry is polled every 60 seconds using `If-None-Match` / `ETag` headers. On steady state (no registry change) S3 returns `304 Not Modified` with no body — avoiding both unnecessary data transfer and stale-config latency without a cache invalidation mechanism. + +### SigV4 authentication at the runtime boundary + +The AgentCore Runtime endpoint is not a public HTTP API. All callers must sign requests with `bedrock-agentcore:InvokeAgentRuntime`. The MCP server code itself is unaware of authentication — IAM is enforced at the runtime layer. Local clients (e.g. Kiro) use `local-proxy/proxy.py`, a stdio-to-SigV4-HTTP bridge that signs requests with the local AWS credentials. + +--- + +## Local Development (Kiro) + +``` +┌──────────────┐ stdio ┌─────────────────┐ SigV4/HTTPS ┌───────────────────────┐ +│ Kiro MCP │────────>│ local-proxy/ │─────────────>│ AgentCore Runtime │ +│ client │ │ proxy.py │ │ (deployed) │ +└──────────────┘ └─────────────────┘ └───────────────────────┘ +``` + +The proxy bridges the stdio transport expected by local MCP clients to the SigV4-signed HTTPS transport required by AgentCore Runtime. `deploy-all` generates `local-proxy/mcp.json` with the runtime ARN and region pre-filled — this file is gitignored since it contains account-specific values. diff --git a/mcp/saas-status-mcp/CHANGELOG.md b/mcp/saas-status-mcp/CHANGELOG.md new file mode 100644 index 0000000..f91af42 --- /dev/null +++ b/mcp/saas-status-mcp/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to the SaaS Status MCP server are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — 2026-08-31 + +Initial release. + +### Added + +- **Four provider-agnostic MCP tools** for correlating AWS DevOps Agent + investigations with upstream SaaS health: + - `list_providers` — returns every provider in the registry (local read, no + external call). + - `get_service_status` — current overall Statuspage.io indicator for one + provider, normalized so `none` maps to `operational`. + - `get_active_events` — the core investigation tool; merges unresolved + incidents and active scheduled maintenances into a single normalized event + list, with optional `include_history` for full update trails. + - `check_all_dependencies` — bulk status + active-event count across up to 10 + providers, fanned out in parallel with `asyncio.gather`. +- **Generic Statuspage.io client.** A single async `httpx` client speaks the + public `/api/v2/*` contract (`status.json`, `incidents/unresolved.json`, + `scheduled-maintenances/active.json`) — covering 80%+ of major SaaS providers + with no provider-specific code and no authentication. +- **28-provider seed registry** (`agent/providers.json`) covering Snowflake, + Datadog, MongoDB, GitHub, PagerDuty, and more. +- **S3-backed live registry with conditional GET.** The running server reads the + provider registry from S3 using ETag / `If-None-Match`, so operators can add or + remove providers by pushing a new `providers.json` (via `refresh-providers`) + with no redeploy. Local development falls back to the repo-local seed. +- **Stateless AgentCore Runtime hosting.** Deployed to Amazon Bedrock AgentCore + Runtime over the `streamable-http` transport (`stateless_http=True`, + `json_response=True`), `PUBLIC` network mode, Python 3.13 — no VPC, no database, + every call a fresh read. +- **SigV4 security model.** The runtime is IAM-protected; DevOps Agent assumes a + dedicated signing role scoped to `bedrock-agentcore:InvokeAgentRuntime` on the + runtime ARN, with a trust policy limited to `aidevops.amazonaws.com` in the + caller's account and Agent Space region. +- **Two IaC paths.** CDK (Python) and Terraform, both producing the same runtime + stack plus an optional DevOps Agent registration stack (SigV4 signing role, + `AWS::DevOpsAgent::Service`, `AWS::DevOpsAgent::Association` enabling the four + tools). +- **One-command deploy scripts** for Windows (PowerShell) and macOS/Linux (bash), + covering both the CDK and Terraform paths, plus `setup-devops-agent` for + registration and `refresh-providers` for live registry updates. +- **Optional stdio testing bridge** (`local-proxy/proxy.py`) that lets a local MCP + client exercise the deployed runtime by SigV4-signing calls with local AWS + credentials — a testing aid only, not a supported production client. +- **Unit tests** (`tests/test_tools.py`) covering all four tools with mocked + Statuspage.io responses (operational, degraded, active incident, active + maintenance, history, bulk-check, and the 10-provider cap), plus an end-to-end + `invoke_test.py` against a deployed runtime. + +[1.0.0]: https://github.com/aws/tools-for-devops-agent diff --git a/mcp/saas-status-mcp/README.md b/mcp/saas-status-mcp/README.md new file mode 100644 index 0000000..050e476 --- /dev/null +++ b/mcp/saas-status-mcp/README.md @@ -0,0 +1,440 @@ +# SaaS Status MCP + +> **DevOps Agent only.** This MCP server is designed exclusively for integration with AWS DevOps Agent via Streamable HTTP + SigV4 (hosted on Amazon Bedrock AgentCore Runtime). It is **not** compatible with local MCP clients such as Kiro, Cursor, or VS Code that use stdio transport. A stdio bridge (`local-proxy/proxy.py`) is included **only** as an optional testing aid — it signs requests with your AWS credentials and forwards them to the deployed runtime; it is not a supported production client. + +> **Disclaimer:** This MCP server is sample code, not intended for production use without additional review and testing. Review the IAM permissions and security model against your organization's policies, and validate behavior in a non-production environment before deploying to production. + +## Overview + +AWS DevOps Agent is a powerful investigation tool — it can dig through pods, logs, databases, and security groups in seconds. But when the real root cause is upstream (Snowflake down, Datadog degraded, a third-party API on fire), that signal lives outside AWS. This MCP server bridges that gap, giving the agent real-time visibility into SaaS health without leaving the investigation flow. + +Today that gap costs operators 15-30 minutes of internal deep-diving before someone manually checks external status pages and finds the issue was upstream all along. + +This server closes that gap with a small, remote MCP server hosted on Amazon Bedrock AgentCore. It exposes SaaS status page data as MCP tools that DevOps Agent can call mid-investigation. Most major SaaS providers run on Atlassian Statuspage.io with the same public `/api/v2/*` contract, so one generic client covers Snowflake, Datadog, GitHub, PagerDuty, and a dozen others without provider-specific code. + +The core question the server answers: **"Is anything happening right now on my upstream dependencies?"** + +## At a Glance + +- **Duration**: ~10 min deployment + ~10 min demo +- **Difficulty**: Beginner +- **Target Audience**: SREs, DevOps Engineers, Platform Engineers, TAMs/SAs demoing DevOps Agent +- **Key Technologies**: Amazon Bedrock AgentCore Runtime, Python 3.12, MCP (streamable-http), Statuspage.io public API, AWS CDK (Python) +- **Estimated Cost**: ~$2-5/month at demo usage levels — see [Estimated Cost Breakdown](#estimated-cost-breakdown) + +## Business Value + +- **Faster root cause identification**: cuts time-to-detect-external-cause from ~25 minutes of manual checking to under 2 minutes of autonomous correlation. +- **Fewer wasted internal investigations**: the agent stops treating an upstream outage as an internal infrastructure problem. +- **Reusable across any customer**: the provider registry is a JSON config, so any DevOps Agent customer can point the same server at their own list of SaaS dependencies with no code changes. +- **Extends DevOps Agent's reach**: pairs natively with the agent's investigation workflow — correlate infrastructure signals with upstream SaaS health in a single conversation, without leaving the agent. + +## What You'll See + +1. A sample customer application is set up as depending on Snowflake (analytics) and Datadog (monitoring). +2. A CloudWatch alarm fires — API latency exceeds 5 seconds. +3. AWS DevOps Agent starts an autonomous investigation and calls `check_all_dependencies` for Snowflake and Datadog. +4. The server reports an active incident on Snowflake with impact `major`. +5. The agent reports: *"Root cause identified: upstream dependency Snowflake is experiencing a major incident. No internal infrastructure issue detected. Monitor https://status.snowflake.com for resolution."* +6. Mean time to (correct) root cause drops from ~25 minutes of manual digging to ~2 minutes of autonomous correlation. + +## Target Providers + +28 providers are included out of the box. 80%+ of major SaaS providers run Atlassian Statuspage.io, so this one client covers all of them via the same `/api/v2/*` contract: + +| Provider | Status Page | API Base | +|----------|-------------|----------| +| Snowflake | status.snowflake.com | status.snowflake.com/api/v2 | +| Datadog | status.datadoghq.com | status.datadoghq.com/api/v2 | +| MongoDB Atlas | status.cloud.mongodb.com | status.cloud.mongodb.com/api/v2 | +| PagerDuty | status.pagerduty.com | status.pagerduty.com/api/v2 | +| Splunk | status.splunk.com | status.splunk.com/api/v2 | +| New Relic | status.newrelic.com | status.newrelic.com/api/v2 | +| GitHub | www.githubstatus.com | www.githubstatus.com/api/v2 | +| GitLab | status.gitlab.com | status.gitlab.com/api/v2 | +| ServiceNow | status.servicenow.com | status.servicenow.com/api/v2 | +| Atlassian/Jira | status.atlassian.com | status.atlassian.com/api/v2 | +| Grafana Cloud | status.grafana.com | status.grafana.com/api/v2 | +| Dynatrace | status.dynatrace.com | status.dynatrace.com/api/v2 | + +Adding a new provider is a JSON entry in the registry — no code change, and no redeploy (the server reads the registry from S3; run `refresh-providers` to push an update). + +### Updating the provider registry + +The provider registry (`agent/providers.json`) is the source-controlled seed. On first deploy it is uploaded to S3, and the running server reads it via a conditional GET — so you can update the live registry without touching code or redeploying. + +**1. Edit `agent/providers.json`** — add or remove an entry following the existing pattern: + +```json +{ + "name": "pagerduty", + "display_name": "PagerDuty", + "statuspage_url": "https://status.pagerduty.com" +} +``` + +**2. Push the update to S3:** + +**Windows (PowerShell):** +```powershell +.\scripts\refresh-providers.ps1 +``` + +**macOS/Linux:** +```bash +./scripts/refresh-providers.sh +``` + +The script uploads `agent/providers.json` to `s3://saas-status-mcp--/config/providers.json` and confirms how many providers are now live. The running server picks up the change within its poll interval (default 60 seconds) — no restart, no CDK, no zip. + +## Interactive Demo + +Experience this demo in an interactive click-through walkthrough: + +▶️ [Launch Interactive Demo](https://amazon.storylane.io/share/4yx03kpuwdor) + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md) for a full breakdown of components, data flow, IAM roles, CDK stacks, and design decisions. + +``` +┌──────────────────┐ ┌─────────────────────────┐ ┌────────────────────┐ +│ AWS DevOps │ MCP │ AgentCore Runtime │ HTTPS │ Statuspage.io │ +│ Agent │───────>│ (saas-status-mcp) │───────>│ Public APIs │ +│ (Investigation) │ │ │ │ (no auth needed) │ +└──────────────────┘ └─────────────────────────┘ └────────────────────┘ + │ + │ Config + ▼ + ┌─────────────────────┐ + │ Provider Registry │ + │ (providers.json) │ + └─────────────────────┘ +``` + +The server is a single stateless Python MCP server, deployed to Amazon Bedrock AgentCore Runtime over the `streamable-http` transport. It reads its saas providers registry from S3 (a conditional GET, so the list can be updated without a redeploy), fans requests out to the relevant Statuspage.io public endpoints (no authentication required), and returns structured results back to DevOps Agent as MCP tool responses. There is no database and no persisted state — every call is a fresh read. + +## Prerequisites + +- AWS CLI v2.31.13+ with configured credentials +- Python 3.12+ +- Node.js 20+ (for the CDK CLI) +- AgentCore available in your target region ([check availability](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html)) +- An existing AWS DevOps Agent Space to register this server against + +## Quick Start + +Two IaC options — CDK (default) or Terraform. Both produce the same result. + +**CDK — Windows (PowerShell):** +```powershell +cd observability\saas-status-mcp +.\deploy-all.ps1 +``` + +**CDK — macOS/Linux:** +```bash +cd observability/saas-status-mcp +./deploy-all.sh +``` + +**Terraform — Windows (PowerShell):** +```powershell +.\deploy-all-terraform.ps1 +``` + +**Terraform — macOS/Linux:** +```bash +./deploy-all-terraform.sh +``` + +Each script: +1. Validates prerequisites +2. Packages and uploads the MCP server to S3 +3. Deploys the runtime infrastructure (AgentCore Runtime + IAM role) +4. Generates `local-proxy/mcp.json` — ready-to-use Kiro config with the runtime ARN pre-filled +5. Offers to register the MCP server with your DevOps Agent Space + +**Time**: ~10 minutes. + +## MCP Tools Exposed + +Four provider-agnostic tools, focused on answering "is anything happening right now?": + +### `list_providers` +Returns every provider configured in the registry (name, display name, status page URL). No external call — reads local config. + +```json +// output +{ + "providers": [ + { "name": "snowflake", "display_name": "Snowflake", "url": "https://status.snowflake.com" }, + { "name": "datadog", "display_name": "Datadog", "url": "https://status.datadoghq.com" } + ] +} +``` + +### `get_service_status` +Current overall status for one provider. A quick, lightweight check — useful as a first-pass test or to confirm a new provider works. + +```json +// input +{ "provider": "snowflake" } + +// output +{ + "provider": "snowflake", + "status": "degraded_performance", + "description": "Degraded Performance", + "last_updated": "2026-07-06T15:30:00Z", + "url": "https://status.snowflake.com" +} +``` + +### `get_active_events` +The core investigation tool. Retrieves all currently active events for a provider by calling both the unresolved incidents and active scheduled maintenances endpoints, then merging and normalizing the results. + +```json +// input +{ "provider": "mongodb" } + +// output (real-world example: MongoDB Atlas unresolved incident) +{ + "provider": "mongodb", + "events": [ + { + "event_type": "incident", + "id": "7g5qmxgkc2y4", + "name": "Impaired Cluster Operations - AWS me-central-1 and AWS me-south-1", + "status": "monitoring", + "impact": "major", + "created_at": "2026-03-01T13:48:14Z", + "updated_at": "2026-06-03T15:46:56Z", + "started_at": "2026-03-01T13:48:14Z", + "resolved_at": null, + "scheduled_for": null, + "scheduled_until": null, + "shortlink": "https://stspg.io/mg7m971rdhw8", + "affected_components": [ + { "name": "Cloud Services - AWS me-central-1", "status": "degraded_performance" }, + { "name": "Cloud Services - AWS me-south-1", "status": "degraded_performance" } + ], + "latest_update": { + "status": "monitoring", + "body": "We are continuing to monitor cluster operations in these regions.", + "created_at": "2026-06-03T15:46:56Z" + } + } + ], + "total_active": 1 +} +``` + +When no events are active (the common case): +```json +{ "provider": "snowflake", "events": [], "total_active": 0 } +``` + +Optional `include_history=true` parameter returns full update history per event. Default is `false` to keep DevOps Agent context lean. + +### `check_all_dependencies` +Bulk check across up to 10 providers in one call, run in parallel internally. Returns status and active event count per provider for quick triage. + +```json +// input +{ "providers": ["snowflake", "datadog", "mongodb"] } + +// output +{ + "results": [ + { "provider": "snowflake", "status": "operational", "active_events": 0 }, + { "provider": "datadog", "status": "operational", "active_events": 0 }, + { "provider": "mongodb", "status": "degraded_performance", "active_events": 1 } + ], + "any_degraded": true, + "degraded_providers": ["mongodb"] +} +``` + +## Consuming the server + +The runtime is protected by IAM (SigV4) — it is not a plain public HTTP endpoint. Callers invoke it through the `bedrock-agentcore:InvokeAgentRuntime` API, signing requests with AWS credentials. There are two consumers: + +### Register with AWS DevOps Agent (intended consumer) + +DevOps Agent runs inside AWS and assumes an IAM role, so it invokes the runtime natively — no proxy. Registration is a property of the Agent Space, so cross-region is fine: the runtime can live in one region (e.g. `eu-west-3`) while the Agent Space and its registration live in another (e.g. `eu-west-1`). + +**Automated (recommended).** Create your Agent Space in the DevOps Agent console first, then run: + +**Windows (PowerShell):** +```powershell +.\scripts\setup-devops-agent.ps1 +``` + +**macOS/Linux:** +```bash +./scripts/setup-devops-agent.sh +``` + +The script prompts for your Agent Space ARN (open the DevOps Agent console and from your space click **Actions > Copy ARN**), auto-detects the runtime ARN and endpoint from the CloudFormation stack, creates the SigV4 signing IAM role, registers the MCP server, and enables the four tools on your space. It is idempotent — safe to re-run. `deploy-all` also offers to run it at the end. + +**Manual.** If you register through the console instead, these are the exact field values (SigV4 authorization config): + +| Field | Value | +|-------|-------| +| **Endpoint / URL** | `https://bedrock-agentcore..amazonaws.com/runtimes//invocations?qualifier=DEFAULT` (the `RuntimeEndpoint` stack output — copy it verbatim) | +| **AWS Region** | the **runtime's** region (e.g. `eu-west-3`) — this is the SigV4 signing region, not the Agent Space region | +| **Service Name** | `bedrock-agentcore` | +| **Role** | the SigV4 signing role below | +| **Custom Headers** | none | +| **Tools** | `list_providers`, `get_service_status`, `get_active_events`, `check_all_dependencies` | + +The endpoint places the **URL-encoded runtime ARN** in the path (`:` -> `%3A`, `/` -> `%2F`) followed by `/invocations?qualifier=DEFAULT`. Use the `RuntimeEndpoint` stack output directly rather than building it by hand. + +**IAM signing role.** DevOps Agent assumes this role to sign the call. Trust policy (`` is your Agent Space's region): + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Service": "aidevops.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" }, + "ArnLike": { "aws:SourceArn": "arn:aws:aidevops:::service/*" } + } + }] +} +``` + +Permission policy (allow invoking this runtime): + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "bedrock-agentcore:InvokeAgentRuntime", + "Resource": [ + "arn:aws:bedrock-agentcore:::runtime/", + "arn:aws:bedrock-agentcore:::runtime//*" + ] + }] +} +``` + +### Test locally from Kiro (optional) + +Local MCP clients can't SigV4-sign, so use the bundled bridge (`local-proxy/proxy.py`) — a stdio MCP server that signs each call with your AWS credentials and forwards to the runtime. + +`deploy-all` generates `local-proxy/mcp.json` with your runtime ARN and region pre-filled. To connect Kiro to the deployed MCP Server on Bedrock AgentCore Runtime: + +```bash +pip install -r local-proxy/requirements.txt +``` + +Then merge `local-proxy/mcp.json` into your Kiro `mcp.json`. The file is gitignored since it contains your account-specific runtime ARN. + +## Estimated Cost Breakdown + +All costs approximate, `us-east-1` pricing. This server makes lightweight outbound HTTPS calls and holds no state, so it's inexpensive to run. + +| Resource | Usage | $/month | +|----------|-------|---------| +| AgentCore Runtime | Low invocation volume (demo-level, a few hundred calls) | $1-3 | +| CloudWatch Logs | Structured JSON request logs | <$1 | +| Data transfer | Small JSON responses from Statuspage.io | <$1 | +| **Total** | | **~$2-5/month** | + +No Bedrock model costs — this server exposes tools only, it does not call an LLM itself. Statuspage.io's public API requires no API key and has no per-call charge. Production cost scales with investigation volume via AgentCore Runtime request/duration pricing. + +## Project Structure + +``` +saas-status-mcp/ +├── README.md # This file +├── deploy-all.ps1 # One-command deploy (Windows) +├── deploy-all.sh # One-command deploy (macOS/Linux) +├── refresh-providers.ps1 # Update the live provider registry (no redeploy) +├── refresh-providers.sh +├── agent/ # MCP server (packaged flat to AgentCore Runtime) +│ ├── main.py # FastMCP entry point — 4 @mcp.tool functions +│ ├── tools.py # Tool implementations +│ ├── statuspage_client.py # Async HTTP client for the Statuspage.io API +│ ├── config.py # Provider registry loader (S3 conditional read) +│ ├── providers.json # Provider registry seed (uploaded to S3 on deploy) +│ └── requirements.txt +├── scripts/ +│ ├── setup-devops-agent.ps1 # Register the MCP server with a DevOps Agent Space +│ ├── setup-devops-agent.sh +│ ├── refresh-providers.ps1 # Update the live provider registry (no redeploy) +│ └── refresh-providers.sh +├── local-proxy/ # SigV4 stdio bridge for local MCP clients (Kiro) +│ ├── proxy.py +│ └── requirements.txt +├── tests/ +│ ├── test_tools.py # Unit tests (mocked Statuspage.io responses) +│ ├── invoke_test.py # Invoke the deployed runtime end-to-end +│ └── fixtures/ # Mock API responses +└── infrastructure/ + ├── cdk/ + │ ├── app.py # CDK app entry point (tracking + region suffix) + │ ├── stack.py # CDK stack: AgentCore Runtime + IAM role + │ ├── registration_stack.py # CDK stack: DevOps Agent registration + │ ├── cdk.json + │ └── requirements.txt + └── terraform/ + ├── main.tf # AgentCore Runtime + IAM role + ├── registration.tf # DevOps Agent registration + ├── variables.tf + ├── outputs.tf + └── terraform.tfvars.example +``` + +The provider registry lives in `agent/providers.json` as the source-controlled seed. On deploy it is uploaded to `s3:///config/providers.json`, and the running server reads it from S3 via a conditional GET. To add or change providers on a live server, edit the registry and run `refresh-providers` — no redeploy needed. + +## CDK Stacks + +| Stack | Region | Purpose | Key Resources | +|-------|--------|---------|---------------| +| `SaasStatusMcpStack-{region}` | Runtime region | MCP server hosting | AgentCore Runtime, Runtime IAM role, CloudWatch log group | +| `SaasStatusMcpRegistrationStack-{space-region}` | Agent Space region | DevOps Agent registration | SigV4 signing role, DevOps Agent Service, Association | + +The registration stack is optional — only deployed when you run `setup-devops-agent`. The Terraform path (`infrastructure/terraform/`) deploys the same resources without CDK. + +## Cleanup + +**CDK — Windows (PowerShell):** +```powershell +cd infrastructure\cdk +npx cdk destroy SaasStatusMcpRegistrationStack- --no-cli-pager +npx cdk destroy SaasStatusMcpStack- --no-cli-pager +``` + +**CDK — macOS/Linux:** +```bash +cd infrastructure/cdk +npx cdk destroy SaasStatusMcpRegistrationStack- +npx cdk destroy SaasStatusMcpStack- +``` + +**Terraform:** +```bash +cd infrastructure/terraform +terraform destroy -auto-approve +``` + +Also delete the S3 bucket (`saas-status-mcp--`) manually — neither CDK nor Terraform manages it. + +## Contributing + +We welcome community contributions! Please see [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. + +## Security + +See [CONTRIBUTING](../../CONTRIBUTING.md#security-issue-notifications) for more information. + +## License + +This library is licensed under the Apache-2.0 License. See the [LICENSE](../../LICENSE) file. diff --git a/mcp/saas-status-mcp/agent/config.py b/mcp/saas-status-mcp/agent/config.py new file mode 100644 index 0000000..eef3c64 --- /dev/null +++ b/mcp/saas-status-mcp/agent/config.py @@ -0,0 +1,116 @@ +"""Provider registry loader. + +At runtime (on AgentCore) the registry lives in S3 as the single source of +truth. The loader uses an S3 conditional GET (If-None-Match / ETag) so it can +poll cheaply: when the object hasn't changed, S3 returns 304 Not Modified with +no body and we keep the cached list. Editing providers.json in S3 propagates +within one poll interval — no redeploy. + +Configuration (env vars, set by CDK on the runtime): + PROVIDERS_BUCKET S3 bucket holding the registry (required for S3 mode) + PROVIDERS_KEY object key (default: config/providers.json) + PROVIDERS_POLL_INTERVAL seconds between conditional refreshes (default: 60) + +Local development: if PROVIDERS_BUCKET is not set, the loader reads the +repo-local providers.json sitting next to this file. That same file is the +seed the deploy script uploads to S3 — so it is never a runtime duplicate. +""" + +import json +import logging +import os +import time +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_BUCKET = os.environ.get("PROVIDERS_BUCKET") +_KEY = os.environ.get("PROVIDERS_KEY", "config/providers.json") +_POLL_INTERVAL = float(os.environ.get("PROVIDERS_POLL_INTERVAL", "60")) +_LOCAL_PATH = Path(__file__).resolve().parent / "providers.json" + +_providers: Optional[list[dict]] = None +_etag: Optional[str] = None +_last_check: float = 0.0 +_s3 = None + + +def _s3_client(): + global _s3 + if _s3 is None: + import boto3 + + _s3 = boto3.client("s3") + return _s3 + + +def _load_from_s3() -> None: + """Conditional GET from S3. On 304 (unchanged) keep the cache; on 200 refresh it.""" + global _providers, _etag + from botocore.exceptions import ClientError + + kwargs = {"Bucket": _BUCKET, "Key": _KEY} + if _etag: + kwargs["IfNoneMatch"] = _etag + + try: + resp = _s3_client().get_object(**kwargs) + body = resp["Body"].read().decode("utf-8") + _providers = json.loads(body).get("providers", []) + _etag = resp.get("ETag") + logger.info("Loaded %d providers from s3://%s/%s", len(_providers), _BUCKET, _KEY) + except ClientError as e: + status = e.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + if status == 304: + logger.debug("providers.json unchanged (304), keeping cache") + else: + logger.error("S3 get_object failed: %s", e) + if _providers is None: + _providers = [] + except json.JSONDecodeError as e: + logger.error("Failed to parse providers.json from S3: %s", e) + if _providers is None: + _providers = [] + + +def _load_local() -> None: + """Local-dev fallback: read the repo-local providers.json once.""" + global _providers + if _providers is not None: + return + try: + with open(_LOCAL_PATH, "r", encoding="utf-8") as f: + _providers = json.load(f).get("providers", []) + logger.info("Loaded %d providers from local file", len(_providers)) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error("Local providers load failed: %s", e) + _providers = [] + + +def _refresh() -> None: + """Refresh the cache from the active source, respecting the poll interval.""" + global _last_check + if _BUCKET: + now = time.time() + if _providers is None or (now - _last_check) >= _POLL_INTERVAL: + _load_from_s3() + _last_check = now + else: + _load_local() + + +def get_providers() -> list[dict]: + _refresh() + return _providers or [] + + +def get_provider(name: str) -> Optional[dict]: + for p in get_providers(): + if p["name"] == name.lower().strip(): + return p + return None + + +def get_provider_names() -> list[str]: + return [p["name"] for p in get_providers()] diff --git a/mcp/saas-status-mcp/agent/main.py b/mcp/saas-status-mcp/agent/main.py new file mode 100644 index 0000000..d80108a --- /dev/null +++ b/mcp/saas-status-mcp/agent/main.py @@ -0,0 +1,65 @@ +"""SaaS Status MCP Server — hosted on AgentCore Runtime. + +Exposes SaaS status page data as MCP tools for AWS DevOps Agent. +AgentCore Runtime expects the MCP server on 0.0.0.0:8000/mcp using +stateless streamable HTTP transport. +""" + +import logging + +from mcp.server.fastmcp import FastMCP + +import tools + +logging.basicConfig(level=logging.INFO) + +# stateless_http=True and json_response=True are REQUIRED for AgentCore Runtime +mcp = FastMCP( + "saas-status-mcp", + host="0.0.0.0", # nosec B104 + stateless_http=True, + json_response=True, +) + + +@mcp.tool() +async def list_providers() -> dict: + """List all configured SaaS providers (name, display name, status page URL).""" + return await tools.list_providers() + + +@mcp.tool() +async def get_service_status(provider: str) -> dict: + """Get the current overall operational status for a SaaS provider. + + Args: + provider: Provider name (e.g. "snowflake", "datadog", "mongodb"). + """ + return await tools.get_service_status(provider) + + +@mcp.tool() +async def get_active_events(provider: str, include_history: bool = False) -> dict: + """Get all active events (unresolved incidents + active maintenances) for a provider. + + Core investigation tool: answers "is anything happening right now?" + + Args: + provider: Provider name (e.g. "snowflake", "datadog", "mongodb"). + include_history: If true, include full update history per event. Default false. + """ + return await tools.get_active_events(provider, include_history=include_history) + + +@mcp.tool() +async def check_all_dependencies(providers: list[str]) -> dict: + """Bulk-check status and active events across multiple providers (max 10) in parallel. + + Args: + providers: List of provider names to check. + """ + return await tools.check_all_dependencies(providers) + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/mcp/saas-status-mcp/agent/providers.json b/mcp/saas-status-mcp/agent/providers.json new file mode 100644 index 0000000..eebefb8 --- /dev/null +++ b/mcp/saas-status-mcp/agent/providers.json @@ -0,0 +1,32 @@ +{ + "providers": [ + { "name": "snowflake", "display_name": "Snowflake", "api_base": "https://status.snowflake.com/api/v2", "url": "https://status.snowflake.com" }, + { "name": "datadog", "display_name": "Datadog", "api_base": "https://status.datadoghq.com/api/v2", "url": "https://status.datadoghq.com" }, + { "name": "mongodb", "display_name": "MongoDB Cloud", "api_base": "https://status.mongodb.com/api/v2", "url": "https://status.mongodb.com" }, + { "name": "github", "display_name": "GitHub", "api_base": "https://www.githubstatus.com/api/v2", "url": "https://www.githubstatus.com" }, + { "name": "gitlab", "display_name": "GitLab", "api_base": "https://status.gitlab.com/api/v2", "url": "https://status.gitlab.com" }, + { "name": "pagerduty", "display_name": "PagerDuty", "api_base": "https://status.pagerduty.com/api/v2", "url": "https://status.pagerduty.com" }, + { "name": "atlassian", "display_name": "Atlassian", "api_base": "https://status.atlassian.com/api/v2", "url": "https://status.atlassian.com" }, + { "name": "cloudflare", "display_name": "Cloudflare", "api_base": "https://www.cloudflarestatus.com/api/v2", "url": "https://www.cloudflarestatus.com" }, + { "name": "twilio", "display_name": "Twilio", "api_base": "https://status.twilio.com/api/v2", "url": "https://status.twilio.com" }, + { "name": "sendgrid", "display_name": "Twilio SendGrid", "api_base": "https://status.sendgrid.com/api/v2", "url": "https://status.sendgrid.com" }, + { "name": "fastly", "display_name": "Fastly", "api_base": "https://www.fastlystatus.com/api/v2", "url": "https://www.fastlystatus.com" }, + { "name": "newrelic", "display_name": "New Relic", "api_base": "https://status.newrelic.com/api/v2", "url": "https://status.newrelic.com" }, + { "name": "auth0", "display_name": "Auth0", "api_base": "https://status.auth0.com/api/v2", "url": "https://status.auth0.com" }, + { "name": "zoom", "display_name": "Zoom", "api_base": "https://status.zoom.us/api/v2", "url": "https://status.zoom.us" }, + { "name": "digitalocean", "display_name": "DigitalOcean", "api_base": "https://status.digitalocean.com/api/v2", "url": "https://status.digitalocean.com" }, + { "name": "discord", "display_name": "Discord", "api_base": "https://discordstatus.com/api/v2", "url": "https://discordstatus.com" }, + { "name": "dropbox", "display_name": "Dropbox", "api_base": "https://status.dropbox.com/api/v2", "url": "https://status.dropbox.com" }, + { "name": "sentry", "display_name": "Sentry", "api_base": "https://status.sentry.io/api/v2", "url": "https://status.sentry.io" }, + { "name": "circleci", "display_name": "CircleCI", "api_base": "https://status.circleci.com/api/v2", "url": "https://status.circleci.com" }, + { "name": "netlify", "display_name": "Netlify", "api_base": "https://www.netlifystatus.com/api/v2", "url": "https://www.netlifystatus.com" }, + { "name": "vercel", "display_name": "Vercel", "api_base": "https://www.vercel-status.com/api/v2", "url": "https://www.vercel-status.com" }, + { "name": "npm", "display_name": "npm", "api_base": "https://status.npmjs.org/api/v2", "url": "https://status.npmjs.org" }, + { "name": "docker", "display_name": "Docker", "api_base": "https://www.dockerstatus.com/api/v2", "url": "https://www.dockerstatus.com" }, + { "name": "hashicorp", "display_name": "HashiCorp", "api_base": "https://status.hashicorp.com/api/v2", "url": "https://status.hashicorp.com" }, + { "name": "elastic", "display_name": "Elastic Cloud", "api_base": "https://status.elastic.co/api/v2", "url": "https://status.elastic.co" }, + { "name": "grafana", "display_name": "Grafana Cloud", "api_base": "https://status.grafana.com/api/v2", "url": "https://status.grafana.com" }, + { "name": "splunk", "display_name": "Splunk", "api_base": "https://status.splunk.com/api/v2", "url": "https://status.splunk.com" }, + { "name": "dynatrace", "display_name": "Dynatrace", "api_base": "https://status.dynatrace.com/api/v2", "url": "https://status.dynatrace.com" } + ] +} diff --git a/mcp/saas-status-mcp/agent/requirements.txt b/mcp/saas-status-mcp/agent/requirements.txt new file mode 100644 index 0000000..9eb29f3 --- /dev/null +++ b/mcp/saas-status-mcp/agent/requirements.txt @@ -0,0 +1,3 @@ +mcp>=1.10.0 +httpx>=0.27.0 +boto3>=1.38.0 diff --git a/mcp/saas-status-mcp/agent/statuspage_client.py b/mcp/saas-status-mcp/agent/statuspage_client.py new file mode 100644 index 0000000..c468fbc --- /dev/null +++ b/mcp/saas-status-mcp/agent/statuspage_client.py @@ -0,0 +1,39 @@ +"""HTTP client for Statuspage.io public API v2 (async, httpx).""" + +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +REQUEST_TIMEOUT = 10.0 + + +async def fetch_status(api_base: str) -> dict[str, Any]: + return await _get(f"{api_base}/status.json") + + +async def fetch_incidents_unresolved(api_base: str) -> dict[str, Any]: + return await _get(f"{api_base}/incidents/unresolved.json") + + +async def fetch_maintenances_active(api_base: str) -> dict[str, Any]: + return await _get(f"{api_base}/scheduled-maintenances/active.json") + + +async def _get(url: str) -> dict[str, Any]: + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + response = await client.get(url) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + logger.warning("Timeout fetching %s", url) + raise + except httpx.HTTPStatusError as e: + logger.warning("HTTP %d from %s", e.response.status_code, url) + raise + except httpx.RequestError as e: + logger.warning("Request error fetching %s: %s", url, e) + raise diff --git a/mcp/saas-status-mcp/agent/tools.py b/mcp/saas-status-mcp/agent/tools.py new file mode 100644 index 0000000..04652ec --- /dev/null +++ b/mcp/saas-status-mcp/agent/tools.py @@ -0,0 +1,198 @@ +"""Tool implementations for the SaaS Status MCP server. + +Four provider-agnostic tools: +- list_providers +- get_service_status +- get_active_events (core investigation tool) +- check_all_dependencies +""" + +import asyncio +import logging +from typing import Any + +from config import get_provider, get_provider_names, get_providers +from statuspage_client import ( + fetch_incidents_unresolved, + fetch_maintenances_active, + fetch_status, +) + +logger = logging.getLogger(__name__) + +MAX_PROVIDERS = 10 + +# Statuspage.io top-level status.json indicators that mean "healthy" +HEALTHY_INDICATORS = {"none"} + + +# ─── list_providers ─────────────────────────────────────────────── + +async def list_providers() -> dict: + """Return every provider configured in providers.json (local read).""" + return { + "providers": [ + {"name": p["name"], "display_name": p["display_name"], "url": p["url"]} + for p in get_providers() + ] + } + + +# ─── get_service_status ─────────────────────────────────────────── + +async def get_service_status(provider: str) -> dict: + """Current overall operational status for one provider.""" + provider_config = get_provider(provider) + if provider_config is None: + return {"error": f"Unknown provider '{provider}'. Available: {get_provider_names()}"} + + try: + data = await fetch_status(provider_config["api_base"]) + status_info = data.get("status", {}) + page_info = data.get("page", {}) + # Statuspage.io uses indicator "none" to mean all-operational; map it for clarity + indicator = status_info.get("indicator", "unknown") + status = "operational" if indicator == "none" else indicator + return { + "provider": provider_config["name"], + "status": status, + "description": status_info.get("description", ""), + "last_updated": page_info.get("updated_at", ""), + "url": provider_config["url"], + } + except Exception as e: + logger.error("Failed to fetch status for %s: %s", provider, e) + return { + "provider": provider_config["name"], + "status": "error", + "description": f"Failed to reach status page: {e}", + "last_updated": "", + "url": provider_config["url"], + } + + +# ─── get_active_events ───────────────────────────────────────────── + +async def get_active_events(provider: str, include_history: bool = False) -> dict: + """All active events (unresolved incidents + active maintenances) for a provider.""" + provider_config = get_provider(provider) + if provider_config is None: + return {"error": f"Unknown provider '{provider}'. Available: {get_provider_names()}"} + + api_base = provider_config["api_base"] + events: list[dict] = [] + + try: + incidents_data, maintenances_data = await asyncio.gather( + fetch_incidents_unresolved(api_base), + fetch_maintenances_active(api_base), + return_exceptions=True, + ) + + if isinstance(incidents_data, dict): + for incident in incidents_data.get("incidents", []): + events.append(_normalize(incident, provider_config["name"], "incident", include_history)) + else: + logger.warning("Failed incidents fetch for %s: %s", provider, incidents_data) + + if isinstance(maintenances_data, dict): + for maint in maintenances_data.get("scheduled_maintenances", []): + events.append(_normalize(maint, provider_config["name"], "maintenance", include_history)) + else: + logger.warning("Failed maintenances fetch for %s: %s", provider, maintenances_data) + + except Exception as e: + logger.error("Unexpected error fetching events for %s: %s", provider, e) + return {"provider": provider_config["name"], "events": [], "total_active": 0, "error": str(e)} + + return {"provider": provider_config["name"], "events": events, "total_active": len(events)} + + +def _normalize(item: dict[str, Any], provider_name: str, event_type: str, include_history: bool) -> dict: + """Normalize a raw incident or maintenance into the unified event shape.""" + updates = item.get("incident_updates", []) + latest = updates[0] if updates else None + + event = { + "event_type": event_type, + "provider": provider_name, + "id": item.get("id", ""), + "name": item.get("name", ""), + "status": item.get("status", ""), + "impact": item.get("impact", "none" if event_type == "incident" else "maintenance"), + "created_at": item.get("created_at", ""), + "updated_at": item.get("updated_at", ""), + "started_at": item.get("started_at") if event_type == "incident" else None, + "resolved_at": item.get("resolved_at") if event_type == "incident" else None, + "scheduled_for": item.get("scheduled_for") if event_type == "maintenance" else None, + "scheduled_until": item.get("scheduled_until") if event_type == "maintenance" else None, + "shortlink": item.get("shortlink", ""), + "affected_components": [ + {"name": c.get("name", ""), "status": c.get("status", "")} + for c in item.get("components", []) + ], + "latest_update": ( + { + "status": latest.get("status", ""), + "body": latest.get("body", ""), + "created_at": latest.get("created_at", ""), + } + if latest + else None + ), + } + + if include_history: + event["updates"] = [ + {"status": u.get("status", ""), "body": u.get("body", ""), "created_at": u.get("created_at", "")} + for u in updates + ] + else: + event["updates"] = None + + return event + + +# ─── check_all_dependencies ──────────────────────────────────────── + +async def check_all_dependencies(providers: list[str]) -> dict: + """Bulk-check status + active event count across multiple providers in parallel.""" + if len(providers) > MAX_PROVIDERS: + return {"error": f"Maximum {MAX_PROVIDERS} providers per call. Got {len(providers)}."} + + available = get_provider_names() + invalid = [p for p in providers if p.lower().strip() not in available] + if invalid: + return {"error": f"Unknown provider(s): {invalid}. Available: {available}"} + + results = await asyncio.gather(*[_check_single(p) for p in providers], return_exceptions=True) + + processed = [] + degraded = [] + for name, result in zip(providers, results): + if isinstance(result, Exception): + logger.error("Error checking %s: %s", name, result) + processed.append({"provider": name, "status": "error", "active_events": 0, "error": str(result)}) + else: + processed.append(result) + # Statuspage.io indicator "none" == all operational; anything else (minor/major/critical) is degraded + if result["status"] not in ("none", "operational"): + degraded.append(result["provider"]) + + return { + "results": processed, + "any_degraded": len(degraded) > 0, + "degraded_providers": degraded, + } + + +async def _check_single(provider: str) -> dict: + status_result, events_result = await asyncio.gather( + get_service_status(provider), + get_active_events(provider), + ) + return { + "provider": status_result["provider"], + "status": status_result["status"], + "active_events": events_result.get("total_active", 0), + } diff --git a/mcp/saas-status-mcp/deploy-all-terraform.ps1 b/mcp/saas-status-mcp/deploy-all-terraform.ps1 new file mode 100644 index 0000000..3175027 --- /dev/null +++ b/mcp/saas-status-mcp/deploy-all-terraform.ps1 @@ -0,0 +1,181 @@ +# deploy-all-terraform.ps1 - One-command Terraform deployment for SaaS Status MCP Server +# Usage: .\deploy-all-terraform.ps1 +# .\deploy-all-terraform.ps1 -AgentSpaceArn arn:aws:aidevops:eu-west-1::agentspace/ + +param( + [string]$AgentSpaceArn = "" +) + +$ErrorActionPreference = "Stop" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " SaaS Status MCP Server - Terraform" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path "$ScriptDir/../..").Path +$TerraformDir = "$ScriptDir/infrastructure/terraform" + +# Check prerequisites +& "$repoRoot/shared/scripts/check-prerequisites.ps1" +$region = $global:AWS_REGION +$account = (aws sts get-caller-identity --query "Account" --output text) + +Write-Host "" +Write-Host "Deploying to region: $region (account: $account)" -ForegroundColor Yellow + +# Step 1: Package the MCP server code +Write-Host "" +Write-Host "[1/4] Packaging MCP server code..." -ForegroundColor Yellow + +$packageDir = "$ScriptDir/build" +$zipPath = "$packageDir/deployment_package.zip" + +if (Test-Path $packageDir) { Remove-Item -Recurse -Force $packageDir } +New-Item -ItemType Directory -Path $packageDir | Out-Null +$stageDir = "$packageDir/stage" +New-Item -ItemType Directory -Path $stageDir | Out-Null + +Copy-Item "$ScriptDir/agent/*.py" "$stageDir/" +uv pip install -r "$ScriptDir/agent/requirements.txt" ` + --python-platform aarch64-unknown-linux-gnu ` + --python-version 3.13 ` + --target "$stageDir" 2>$null + +Get-ChildItem -Path $stageDir -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force +Get-ChildItem -Path $stageDir -Recurse -Include "*.pyc","*.pyo" | Remove-Item -Force + +Compress-Archive -Path "$stageDir/*" -DestinationPath $zipPath -Force +$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2) +Write-Host " Package created: $zipSize MB" -ForegroundColor Gray + +# Step 2: Upload to S3 +# The bucket is also declared in main.tf; we create it here first so the zip +# upload succeeds before Terraform references it in the runtime resource. +Write-Host "[2/4] Uploading deployment package to S3..." -ForegroundColor Yellow + +$bucketName = "saas-status-mcp-$account-$region" +$bucketExists = aws s3api head-bucket --bucket $bucketName 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host " Creating S3 bucket: $bucketName" -ForegroundColor Gray + if ($region -eq "us-east-1") { + aws s3api create-bucket --bucket $bucketName | Out-Null + } else { + aws s3api create-bucket --bucket $bucketName ` + --create-bucket-configuration LocationConstraint=$region | Out-Null + } +} + +aws s3 cp $zipPath "s3://$bucketName/agent/deployment_package.zip" --quiet +Write-Host " Uploaded deployment zip" -ForegroundColor Gray +aws s3 cp "$ScriptDir/agent/providers.json" "s3://$bucketName/config/providers.json" --quiet +Write-Host " Uploaded provider registry" -ForegroundColor Gray + +# Step 3: Generate terraform.tfvars +Write-Host "[3/4] Configuring Terraform..." -ForegroundColor Yellow + +if (-not $AgentSpaceArn) { + Write-Host "" + Write-Host " To register with DevOps Agent, provide your Agent Space ARN." -ForegroundColor White + Write-Host " Open the DevOps Agent console and from your space click Actions > Copy ARN." -ForegroundColor Gray + Write-Host " Leave blank to deploy the runtime only." -ForegroundColor Gray + $AgentSpaceArn = Read-Host " Agent Space ARN (optional)" +} + +$tfvarsLines = @( + "runtime_region = `"$region`"", + "account_id = `"$account`"" +) +if ($AgentSpaceArn) { + $tfvarsLines += "agent_space_arn = `"$AgentSpaceArn`"" +} + +$tfvarsPath = "$TerraformDir/terraform.tfvars" +[System.IO.File]::WriteAllLines($tfvarsPath, $tfvarsLines, (New-Object System.Text.UTF8Encoding($false))) +Write-Host " terraform.tfvars written" -ForegroundColor Gray + +# Step 4: Terraform init + apply +# AWS_SDK_LOAD_CONFIG=0 prevents Terraform from reading ~/.aws/config, which +# avoids a "source_profile requires role_arn" error when the config has an +# incomplete source_profile entry. Terraform falls back to static credentials +# from ~/.aws/credentials instead. +Write-Host "[4/4] Running terraform apply..." -ForegroundColor Yellow +Write-Host "" + +$env:AWS_SDK_LOAD_CONFIG = "0" + +Push-Location $TerraformDir +terraform init -upgrade +$exitCode = $LASTEXITCODE +if ($exitCode -ne 0) { Pop-Location; Write-Host "ERROR: terraform init failed." -ForegroundColor Red; exit 1 } +terraform apply -auto-approve +$exitCode = $LASTEXITCODE +Pop-Location +Remove-Item Env:\AWS_SDK_LOAD_CONFIG -ErrorAction SilentlyContinue + +if ($exitCode -ne 0) { + Write-Host "" + Write-Host "ERROR: terraform apply failed. See errors above." -ForegroundColor Red + Remove-Item -Recurse -Force $packageDir -ErrorAction SilentlyContinue + exit 1 +} + +# Read outputs +Push-Location $TerraformDir +$runtimeArn = (terraform output -raw runtime_arn 2>$null) +$s3Bucket = (terraform output -raw s3_bucket 2>$null) +$logGroup = (terraform output -raw log_group 2>$null) +Pop-Location + +# Generate local-proxy/mcp.json +$proxyPath = "$($ScriptDir.Replace('\','/') )/local-proxy/proxy.py" +$mcpConfigPath = "$ScriptDir/local-proxy/mcp.json" +$mcpLines = @( + '{', + ' "mcpServers": {', + ' "saas-status-mcp": {', + ' "command": "python",', + " `"args`": [`"$proxyPath`"],", + ' "env": {', + " `"SAAS_MCP_RUNTIME_ARN`": `"$runtimeArn`",", + " `"AWS_REGION`": `"$region`"", + ' },', + ' "disabled": false,', + ' "autoApprove": [', + ' "list_providers",', + ' "get_service_status",', + ' "get_active_events",', + ' "check_all_dependencies"', + ' ]', + ' }', + ' }', + '}' +) +[System.IO.File]::WriteAllLines($mcpConfigPath, $mcpLines, (New-Object System.Text.UTF8Encoding($false))) + +# Cleanup +Remove-Item -Recurse -Force $packageDir + +# Summary +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " Deployment Complete!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "" +Write-Host " Region: $region" -ForegroundColor Cyan +Write-Host " Runtime ARN: $runtimeArn" -ForegroundColor Cyan +Write-Host " S3 bucket: $s3Bucket" -ForegroundColor Cyan +Write-Host " Log group: $logGroup" -ForegroundColor Cyan +Write-Host "" +Write-Host " The runtime is IAM-protected (SigV4). See README 'Consuming the server'." -ForegroundColor Gray +Write-Host "" +Write-Host "========================================" -ForegroundColor Yellow +Write-Host " Test locally from Kiro (optional)" -ForegroundColor Yellow +Write-Host "========================================" -ForegroundColor Yellow +Write-Host "" +Write-Host " mcp.json written to: local-proxy/mcp.json" -ForegroundColor Green +Write-Host "" +Write-Host " To connect Kiro to the deployed MCP Server on Bedrock AgentCore Runtime:" -ForegroundColor White +Write-Host " 1) pip install -r local-proxy/requirements.txt" -ForegroundColor White +Write-Host " 2) Merge local-proxy/mcp.json into your Kiro mcp.json" -ForegroundColor White +Write-Host "" diff --git a/mcp/saas-status-mcp/deploy-all-terraform.sh b/mcp/saas-status-mcp/deploy-all-terraform.sh new file mode 100644 index 0000000..d1d1848 --- /dev/null +++ b/mcp/saas-status-mcp/deploy-all-terraform.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# deploy-all-terraform.sh - One-command Terraform deployment for SaaS Status MCP Server +# Usage: ./deploy-all-terraform.sh +# AGENT_SPACE_ARN=arn:aws:aidevops:eu-west-1::agentspace/ ./deploy-all-terraform.sh + +set -euo pipefail +export AWS_PAGER="" + +AGENT_SPACE_ARN="${AGENT_SPACE_ARN:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TERRAFORM_DIR="${SCRIPT_DIR}/infrastructure/terraform" + +echo "========================================" +echo " SaaS Status MCP Server - Terraform" +echo "========================================" + +# Check prerequisites +source "${REPO_ROOT}/shared/scripts/check-prerequisites.sh" +REGION="${AWS_REGION}" +ACCOUNT=$(aws sts get-caller-identity --query "Account" --output text) + +echo "" +echo "Deploying to region: ${REGION} (account: ${ACCOUNT})" + +# Step 1: Package the MCP server code +echo "" +echo "[1/4] Packaging MCP server code..." + +PACKAGE_DIR="${SCRIPT_DIR}/build" +ZIP_PATH="${PACKAGE_DIR}/deployment_package.zip" + +rm -rf "${PACKAGE_DIR}" +mkdir -p "${PACKAGE_DIR}/stage" +STAGE_DIR="${PACKAGE_DIR}/stage" + +cp "${SCRIPT_DIR}/agent/"*.py "${STAGE_DIR}/" +uv pip install -r "${SCRIPT_DIR}/agent/requirements.txt" \ + --python-platform aarch64-unknown-linux-gnu \ + --python-version 3.13 \ + --target "${STAGE_DIR}" 2>/dev/null + +find "${STAGE_DIR}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +find "${STAGE_DIR}" -name "*.pyc" -o -name "*.pyo" | xargs rm -f 2>/dev/null || true + +cd "${STAGE_DIR}" && zip -qr "${ZIP_PATH}" . && cd "${SCRIPT_DIR}" +ZIP_SIZE=$(du -m "${ZIP_PATH}" | cut -f1) +echo " Package created: ${ZIP_SIZE} MB" + +# Step 2: Upload to S3 +echo "[2/4] Uploading deployment package to S3..." + +BUCKET_NAME="saas-status-mcp-${ACCOUNT}-${REGION}" +if ! aws s3api head-bucket --bucket "${BUCKET_NAME}" 2>/dev/null; then + echo " Creating S3 bucket: ${BUCKET_NAME}" + if [ "${REGION}" = "us-east-1" ]; then + aws s3api create-bucket --bucket "${BUCKET_NAME}" >/dev/null + else + aws s3api create-bucket --bucket "${BUCKET_NAME}" \ + --create-bucket-configuration LocationConstraint="${REGION}" >/dev/null + fi +fi + +aws s3 cp "${ZIP_PATH}" "s3://${BUCKET_NAME}/agent/deployment_package.zip" --quiet +echo " Uploaded deployment zip" +aws s3 cp "${SCRIPT_DIR}/agent/providers.json" "s3://${BUCKET_NAME}/config/providers.json" --quiet +echo " Uploaded provider registry" + +# Step 3: Generate terraform.tfvars +echo "[3/4] Configuring Terraform..." + +if [ -z "${AGENT_SPACE_ARN}" ]; then + echo "" + echo " To register with DevOps Agent, provide your Agent Space ARN." + echo " Open the DevOps Agent console and from your space click Actions > Copy ARN." + echo " Leave blank to deploy the runtime only." + read -r -p " Agent Space ARN (optional): " AGENT_SPACE_ARN || true +fi + +TFVARS_PATH="${TERRAFORM_DIR}/terraform.tfvars" +cat > "${TFVARS_PATH}" <> "${TFVARS_PATH}" +fi +echo " terraform.tfvars written" + +# Step 4: Terraform init + apply +echo "[4/4] Running terraform apply..." +echo "" + +# AWS_SDK_LOAD_CONFIG=0 prevents Terraform from reading ~/.aws/config which +# can cause "source_profile requires role_arn" errors on some configurations. +export AWS_SDK_LOAD_CONFIG=0 + +pushd "${TERRAFORM_DIR}" > /dev/null +terraform init -upgrade +terraform apply -auto-approve +popd > /dev/null + +unset AWS_SDK_LOAD_CONFIG + +# Read outputs +pushd "${TERRAFORM_DIR}" > /dev/null +RUNTIME_ARN=$(terraform output -raw runtime_arn 2>/dev/null || echo "") +S3_BUCKET=$(terraform output -raw s3_bucket 2>/dev/null || echo "") +LOG_GROUP=$(terraform output -raw log_group 2>/dev/null || echo "") +popd > /dev/null + +# Generate local-proxy/mcp.json +MCP_CONFIG_PATH="${SCRIPT_DIR}/local-proxy/mcp.json" +cat > "${MCP_CONFIG_PATH}" <$null + +# Remove Python cache files incompatible with target runtime +Get-ChildItem -Path $stageDir -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force +Get-ChildItem -Path $stageDir -Recurse -Include "*.pyc","*.pyo" | Remove-Item -Force + +# Create zip +Compress-Archive -Path "$stageDir/*" -DestinationPath $zipPath -Force +$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2) +Write-Host " Package created: $zipSize MB" -ForegroundColor Gray + +# ─── Step 2: Create S3 bucket and upload deployment package ─── +Write-Host "[2/4] Uploading deployment package to S3..." -ForegroundColor Yellow + +$bucketName = "saas-status-mcp-$account-$region" + +# Create bucket if it doesn't exist +$bucketExists = aws s3api head-bucket --bucket $bucketName 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host " Creating S3 bucket: $bucketName" -ForegroundColor Gray + if ($region -eq "us-east-1") { + aws s3api create-bucket --bucket $bucketName | Out-Null + } else { + aws s3api create-bucket --bucket $bucketName --create-bucket-configuration LocationConstraint=$region | Out-Null + } +} + +aws s3 cp $zipPath "s3://$bucketName/agent/deployment_package.zip" --quiet +Write-Host " Uploaded to s3://$bucketName/agent/deployment_package.zip" -ForegroundColor Gray + +# Upload the provider registry as a standalone config object. +# The running server reads this via conditional GET, so updating providers +# later is just a re-upload (see scripts/refresh-providers.ps1) — no redeploy needed. +aws s3 cp "$ScriptDir/agent/providers.json" "s3://$bucketName/config/providers.json" --quiet +Write-Host " Uploaded to s3://$bucketName/config/providers.json" -ForegroundColor Gray + +# ─── Step 3: Deploy CDK stack (creates IAM role + AgentCore Runtime) ─── +Write-Host "[3/4] Deploying CDK stack (IAM + AgentCore Runtime)..." -ForegroundColor Yellow + +Push-Location "$ScriptDir/infrastructure/cdk" +python -m pip install -r requirements.txt --quiet 2>$null +npx cdk deploy --require-approval never +if ($LASTEXITCODE -ne 0) { + Pop-Location + Write-Host "" + Write-Host "ERROR: CDK deployment failed. See errors above." -ForegroundColor Red + exit 1 +} +Pop-Location + +# ─── Step 4: Retrieve outputs and display config ─── +Write-Host "[4/4] Retrieving deployment outputs..." -ForegroundColor Yellow + +$stackName = "SaasStatusMcpStack-$region" +$outputs = aws cloudformation describe-stacks --stack-name $stackName --query "Stacks[0].Outputs" --output json 2>$null | ConvertFrom-Json + +$runtimeArn = ($outputs | Where-Object { $_.OutputKey -eq "RuntimeArn" }).OutputValue +$runtimeEndpoint = ($outputs | Where-Object { $_.OutputKey -eq "RuntimeEndpoint" }).OutputValue +$runtimeRoleArn = ($outputs | Where-Object { $_.OutputKey -eq "RuntimeRoleArn" }).OutputValue +$logGroupName = ($outputs | Where-Object { $_.OutputKey -eq "LogGroupName" }).OutputValue + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " Deployment Complete!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "" +Write-Host " Stack: $stackName" -ForegroundColor Cyan +Write-Host " Region: $region" -ForegroundColor Cyan +Write-Host " Runtime ARN: $runtimeArn" -ForegroundColor Cyan +Write-Host " MCP Endpoint: $runtimeEndpoint" -ForegroundColor Cyan +Write-Host " Runtime Role: $runtimeRoleArn" -ForegroundColor Cyan +Write-Host " Log Group: $logGroupName" -ForegroundColor Cyan +Write-Host "" +Write-Host " The runtime is IAM-protected (SigV4) - callers sign requests via the" -ForegroundColor Gray +Write-Host " InvokeAgentRuntime API. See README 'Consuming the server' for details." -ForegroundColor Gray + +# Clean up build artifacts +Remove-Item -Recurse -Force $packageDir + +# ─── Register with AWS DevOps Agent (optional, interactive) ─── +Write-Host "" +Write-Host "========================================" -ForegroundColor Yellow +Write-Host " Register with AWS DevOps Agent" -ForegroundColor Yellow +Write-Host "========================================" -ForegroundColor Yellow +Write-Host "" +$register = Read-Host " Register this MCP server with a DevOps Agent Space now? (y/N)" +if ($register -match '^[Yy]') { + & "$ScriptDir/scripts/setup-devops-agent.ps1" -RuntimeRegion $region +} else { + Write-Host "" + Write-Host " Skipped. Run it anytime with:" -ForegroundColor Gray + Write-Host " .\scripts\setup-devops-agent.ps1 -RuntimeRegion $region" -ForegroundColor Cyan +} + +# ─── Kiro / local MCP clients ─── +# Generate local-proxy/mcp.json with the real runtime ARN and region baked in. +# Users just point Kiro at this file — no manual copy-paste or URL construction. +$mcpConfigPath = "$ScriptDir/local-proxy/mcp.json" + +$mcpConfig = @" +{ + "mcpServers": { + "saas-status-mcp": { + "command": "python", + "args": ["$ScriptDir/local-proxy/proxy.py"], + "env": { + "SAAS_MCP_RUNTIME_ARN": "$runtimeArn", + "AWS_REGION": "$region" + }, + "disabled": false, + "autoApprove": [ + "list_providers", + "get_service_status", + "get_active_events", + "check_all_dependencies" + ] + } + } +} +"@ + +$mcpConfig | Out-File -FilePath $mcpConfigPath -Encoding utf8 -Force + +Write-Host "" +Write-Host "========================================" -ForegroundColor Yellow +Write-Host " Test locally from Kiro (optional)" -ForegroundColor Yellow +Write-Host "========================================" -ForegroundColor Yellow +Write-Host "" +Write-Host " mcp.json written to: local-proxy/mcp.json" -ForegroundColor Green +Write-Host "" +Write-Host " To connect Kiro to the deployed MCP Server on Bedrock AgentCore Runtime:" -ForegroundColor White +Write-Host " 1) pip install -r local-proxy/requirements.txt" -ForegroundColor White +Write-Host " 2) Merge local-proxy/mcp.json into your Kiro mcp.json" -ForegroundColor White +Write-Host "" diff --git a/mcp/saas-status-mcp/deploy-all.sh b/mcp/saas-status-mcp/deploy-all.sh new file mode 100644 index 0000000..6ac7ce8 --- /dev/null +++ b/mcp/saas-status-mcp/deploy-all.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# deploy-all.sh — One-command deployment for SaaS Status MCP Server +# Usage: ./deploy-all.sh + +set -euo pipefail + +echo "========================================" +echo " SaaS Status MCP Server - Deployment" +echo "========================================" + +# Resolve paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# Set PYTHONPATH so CDK app can import shared/utils +export PYTHONPATH="${REPO_ROOT}" + +# Check prerequisites +source "${REPO_ROOT}/shared/scripts/check-prerequisites.sh" +REGION="${AWS_REGION}" +ACCOUNT=$(aws sts get-caller-identity --query "Account" --output text) + +echo "" +echo "Deploying to region: ${REGION} (account: ${ACCOUNT})" + +# ─── Step 1: Package the MCP server code ─── +echo "" +echo "[1/4] Packaging MCP server code..." + +PACKAGE_DIR="${SCRIPT_DIR}/build" +ZIP_PATH="${PACKAGE_DIR}/deployment_package.zip" +STAGE_DIR="${PACKAGE_DIR}/stage" + +rm -rf "${PACKAGE_DIR}" +mkdir -p "${STAGE_DIR}" + +# Copy agent code flat into the staging dir (main.py must be at the zip root). +# NOTE: providers.json is deliberately NOT bundled — it lives in S3 as the single +# runtime source of truth (uploaded separately below). This avoids shipping the +# registry in two places. +cp "${SCRIPT_DIR}"/agent/*.py "${STAGE_DIR}/" + +# Install dependencies for Linux target (AgentCore runs on Linux) +uv pip install -r "${SCRIPT_DIR}/agent/requirements.txt" \ + --python-platform aarch64-unknown-linux-gnu \ + --python-version 3.13 \ + --target "${STAGE_DIR}" > /dev/null 2>&1 + +# Remove Python cache files incompatible with the target runtime +find "${STAGE_DIR}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +find "${STAGE_DIR}" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true + +# Create zip +( cd "${STAGE_DIR}" && zip -r -q "${ZIP_PATH}" . ) +ZIP_SIZE=$(du -m "${ZIP_PATH}" | cut -f1) +echo " Package created: ${ZIP_SIZE} MB" + +# ─── Step 2: Create S3 bucket and upload deployment package ─── +echo "[2/4] Uploading deployment package to S3..." + +BUCKET_NAME="saas-status-mcp-${ACCOUNT}-${REGION}" + +if ! aws s3api head-bucket --bucket "${BUCKET_NAME}" 2>/dev/null; then + echo " Creating S3 bucket: ${BUCKET_NAME}" + if [ "${REGION}" = "us-east-1" ]; then + aws s3api create-bucket --bucket "${BUCKET_NAME}" > /dev/null + else + aws s3api create-bucket --bucket "${BUCKET_NAME}" \ + --create-bucket-configuration LocationConstraint="${REGION}" > /dev/null + fi +fi + +aws s3 cp "${ZIP_PATH}" "s3://${BUCKET_NAME}/agent/deployment_package.zip" --quiet +echo " Uploaded to s3://${BUCKET_NAME}/agent/deployment_package.zip" + +# Upload the provider registry as a standalone config object. +# The running server reads this via conditional GET, so updating providers +# later is just a re-upload (see scripts/refresh-providers.sh) — no redeploy needed. +aws s3 cp "${SCRIPT_DIR}/agent/providers.json" "s3://${BUCKET_NAME}/config/providers.json" --quiet +echo " Uploaded to s3://${BUCKET_NAME}/config/providers.json" + +# ─── Step 3: Deploy CDK stack (creates IAM role + AgentCore Runtime) ─── +echo "[3/4] Deploying CDK stack (IAM + AgentCore Runtime)..." + +pushd "${SCRIPT_DIR}/infrastructure/cdk" > /dev/null +python3 -m pip install -r requirements.txt --quiet +npx cdk deploy --require-approval never +popd > /dev/null + +# ─── Step 4: Retrieve outputs and display config ─── +echo "[4/4] Retrieving deployment outputs..." + +STACK_NAME="SaasStatusMcpStack-${REGION}" +RUNTIME_ARN=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='RuntimeArn'].OutputValue" --output text 2>/dev/null || echo "N/A") +RUNTIME_ENDPOINT=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='RuntimeEndpoint'].OutputValue" --output text 2>/dev/null || echo "N/A") +RUNTIME_ROLE_ARN=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='RuntimeRoleArn'].OutputValue" --output text 2>/dev/null || echo "N/A") +LOG_GROUP_NAME=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='LogGroupName'].OutputValue" --output text 2>/dev/null || echo "N/A") + +echo "" +echo "========================================" +echo " Deployment Complete!" +echo "========================================" +echo "" +echo " Stack: ${STACK_NAME}" +echo " Region: ${REGION}" +echo " Runtime ARN: ${RUNTIME_ARN}" +echo " MCP Endpoint: ${RUNTIME_ENDPOINT}" +echo " Runtime Role: ${RUNTIME_ROLE_ARN}" +echo " Log Group: ${LOG_GROUP_NAME}" +echo "" +echo " The runtime is IAM-protected (SigV4) - callers sign requests via the" +echo " InvokeAgentRuntime API. See README 'Consuming the server' for details." + +# Clean up build artifacts +rm -rf "${PACKAGE_DIR}" + +# ─── Register with AWS DevOps Agent (optional, interactive) ─── +echo "" +echo "========================================" +echo " Register with AWS DevOps Agent" +echo "========================================" +echo "" +echo " DevOps Agent is the intended consumer. It invokes the runtime" +echo " natively (no proxy) once registered against your Agent Space." +echo "" +REGISTER="" +read -r -p " Register this MCP server with a DevOps Agent Space now? (y/N) " REGISTER || true +if [[ "${REGISTER}" =~ ^[Yy] ]]; then + RUNTIME_REGION="${REGION}" "${SCRIPT_DIR}/scripts/setup-devops-agent.sh" +else + echo "" + echo " Skipped. Run it anytime with:" + echo " RUNTIME_REGION=${REGION} ./scripts/setup-devops-agent.sh" +fi + +# ─── Kiro / local MCP clients ─── +# Generate local-proxy/mcp.json with the real runtime ARN and region baked in. +# Users just point Kiro at this file — no manual copy-paste or URL construction. +MCP_CONFIG_PATH="${SCRIPT_DIR}/local-proxy/mcp.json" + +cat > "${MCP_CONFIG_PATH}" < + Deploys the AgentCore Runtime, IAM role, S3 bucket reference, and + CloudWatch log group. Deployed to the runtime's region. + + SaasStatusMcpRegistrationStack- (optional) + Registers the runtime with an AWS DevOps Agent Space: creates the SigV4 + signing IAM role, the account-level Service record, and the Association + that attaches it to the space with the four MCP tools. + Deployed to the Agent Space's region (cross-region from the runtime). + + This stack is only synthesised when the required context variables are + present. They are provided by scripts/setup-devops-agent.ps1|.sh, which + parses the Agent Space ARN and reads the runtime ARN from the main stack + CloudFormation outputs before invoking `cdk deploy`. + + Required CDK context keys: + agent_space_id — Agent Space UUID + agent_space_region — region the Agent Space lives in + runtime_arn — AgentCore Runtime ARN + runtime_region — region the runtime lives in +""" + +import aws_cdk as cdk +from stack import SaasStatusMcpStack +from registration_stack import SaasStatusMcpRegistrationStack +from shared.utils import get_region + +app = cdk.App() + +# ── Main stack: AgentCore Runtime ───────────────────────────────────────────── +runtime_region = get_region() + +SaasStatusMcpStack( + app, + f"SaasStatusMcpStack-{runtime_region}", + env={"region": runtime_region}, + description=( + "SaaS status MCP server for AWS DevOps Agent " + "(uksb-do9bhieqqh)(tag:saas-status-mcp,observability)" + ), +) + +# ── Registration stack: DevOps Agent Service + Association ──────────────────── +# Only synthesised when the Agent Space context is supplied. +agent_space_id = app.node.try_get_context("agent_space_id") +agent_space_region = app.node.try_get_context("agent_space_region") +runtime_arn = app.node.try_get_context("runtime_arn") + +if agent_space_id and agent_space_region and runtime_arn: + SaasStatusMcpRegistrationStack( + app, + f"SaasStatusMcpRegistrationStack-{agent_space_region}", + env={"region": agent_space_region}, + description="DevOps Agent registration for the SaaS Status MCP server", + ) + +app.synth() diff --git a/mcp/saas-status-mcp/infrastructure/cdk/cdk.json b/mcp/saas-status-mcp/infrastructure/cdk/cdk.json new file mode 100644 index 0000000..5993d9c --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/cdk/cdk.json @@ -0,0 +1,19 @@ +{ + "app": "python3 app.py", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "**/__pycache__", + "**/*.pyc", + "cdk.out" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"] + } +} diff --git a/mcp/saas-status-mcp/infrastructure/cdk/registration_stack.py b/mcp/saas-status-mcp/infrastructure/cdk/registration_stack.py new file mode 100644 index 0000000..4dd7c29 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/cdk/registration_stack.py @@ -0,0 +1,181 @@ +"""CDK Stack for SaaS Status MCP Server — DevOps Agent Registration. + +Registers the deployed AgentCore Runtime as an MCP tool source on an existing +AWS DevOps Agent Space. This stack is deployed to the Agent Space's region, +which may differ from the runtime's region. + +Context variables (passed via --context at deploy time): + agent_space_id (str, required) — the Agent Space UUID + agent_space_region (str, required) — region the Agent Space lives in + runtime_arn (str, required) — AgentCore Runtime ARN + runtime_region (str, required) — region the runtime lives in (SigV4 signing region) + service_name (str, optional, default "saas-status-mcp") — name shown in console + +All context values are set automatically by scripts/setup-devops-agent.ps1|.sh, +which parses the agent_space_region from the provided Agent Space ARN and +reads the runtime_arn from the main stack's CloudFormation outputs. +""" + +import aws_cdk as cdk +from aws_cdk import ( + Stack, + aws_devopsagent as devopsagent, + aws_iam as iam, + CfnOutput, +) +from constructs import Construct + +# The four MCP tools the server exposes (must match agent/main.py exactly). +MCP_TOOLS = [ + "list_providers", + "get_service_status", + "get_active_events", + "check_all_dependencies", +] + + +class SaasStatusMcpRegistrationStack(Stack): + """Registers the SaaS Status MCP runtime with a DevOps Agent Space. + + Deployed to the Agent Space's region. Takes runtime details as CDK context + because CloudFormation cross-region output imports are not supported. + """ + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # ── Context ─────────────────────────────────────────────────────────── + agent_space_id = self.node.get_context("agent_space_id") + agent_space_region = self.node.get_context("agent_space_region") # parsed from ARN by deploy script + runtime_arn = self.node.get_context("runtime_arn") + runtime_region = self.node.get_context("runtime_region") + service_name = self.node.try_get_context("service_name") or "saas-status-mcp" + + account = cdk.Aws.ACCOUNT_ID + # Note: cdk.Aws.REGION is a token, not a string. Use agent_space_region + # (from context) for string operations such as role names and ARN conditions. + + # Build the MCP invocation endpoint. + # The runtime ARN is percent-encoded into the path with Fn.join/split + # (same technique as the main stack's RuntimeEndpoint output). + # We build the whole URL with Fn.join so that the CFN token for the + # encoded ARN is embedded as a real CFN function, not a Python repr. + encoded_arn = cdk.Fn.join( + "%2F", + cdk.Fn.split( + "/", + cdk.Fn.join( + "%3A", + cdk.Fn.split(":", runtime_arn), + ), + ), + ) + endpoint = cdk.Fn.join("", [ + f"https://bedrock-agentcore.{runtime_region}.amazonaws.com/runtimes/", + encoded_arn, + "/invocations?qualifier=DEFAULT", + ]) + + # ── IAM signing role ────────────────────────────────────────────────── + # DevOps Agent assumes this role to SigV4-sign calls to the runtime. + # Trust is scoped to aidevops.amazonaws.com within this account and to + # DevOps Agent services in the space's region (aws:SourceArn condition). + signing_role = iam.Role( + self, + "SaasStatusMcpSigningRole", + role_name=f"SaasStatusMcpSigningRole-{agent_space_region}", + assumed_by=iam.ServicePrincipal( + "aidevops.amazonaws.com", + conditions={ + "StringEquals": {"aws:SourceAccount": account}, + # ArnLike scope: only DevOps Agent services in this account and region + # can assume this role. account is cdk.Aws.ACCOUNT_ID (CFN token), + # which resolves to the real account ID at deploy time. + "ArnLike": { + "aws:SourceArn": "arn:aws:aidevops:" + + agent_space_region + + ":" + + account + + ":service/*" + }, + }, + ), + description=( + "SigV4 signing role - DevOps Agent assumes this to invoke " + "the SaaS Status MCP AgentCore Runtime" + ), + ) + + # Permission: invoke the runtime (and any qualifier endpoints). + signing_role.add_to_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["bedrock-agentcore:InvokeAgentRuntime"], + resources=[runtime_arn, f"{runtime_arn}/*"], + ) + ) + + # ── DevOps Agent Service (account-level MCP registration) ───────────── + # AWS::DevOpsAgent::Service registers the MCP server in this account. + # It is account-scoped and shared across all Agent Spaces. + # + # IMPORTANT: CfnService validates the role by actually calling + # InvokeAgentRuntime during CREATE. Without an explicit dependency, CFN + # can create the Service before the inline policy is attached, causing a + # 403. add_dependency ensures the role + policy are fully in place first. + mcp_service = devopsagent.CfnService( + self, + "SaasStatusMcpService", + service_type="mcpserversigv4", + service_details=devopsagent.CfnService.ServiceDetailsProperty( + mcp_server_sig_v4=devopsagent.CfnService.MCPServerSigV4DetailsProperty( + name=service_name, + endpoint=endpoint, + description="SaaS status pages (Statuspage.io) for upstream dependency checks", + authorization_config=devopsagent.CfnService.MCPServerSigV4AuthorizationConfigProperty( + region=runtime_region, # SigV4 signing region = runtime's region + service="bedrock-agentcore", + role_arn=signing_role.role_arn, + ), + ) + ), + ) + # Block CfnService creation until the role AND its inline policy exist. + mcp_service.node.add_dependency(signing_role) + + # ── DevOps Agent Association (attach to the Agent Space + enable tools) ─ + # AWS::DevOpsAgent::Association attaches the service to a specific Agent + # Space and specifies which tools are allowed. + devopsagent.CfnAssociation( + self, + "SaasStatusMcpAssociation", + agent_space_id=agent_space_id, + service_id=mcp_service.attr_service_id, + configuration=devopsagent.CfnAssociation.ServiceConfigurationProperty( + mcp_server_sig_v4=devopsagent.CfnAssociation.MCPServerSigV4ConfigurationProperty( + tools=MCP_TOOLS, + ) + ), + ) + + # ── Outputs ────────────────────────────────────────────────────────── + CfnOutput( + self, + "ServiceId", + value=mcp_service.attr_service_id, + description="DevOps Agent Service ID for the MCP registration", + ) + + CfnOutput( + self, + "SigningRoleArn", + value=signing_role.role_arn, + description="IAM role DevOps Agent assumes to invoke the runtime (SigV4)", + ) + + CfnOutput( + self, + "McpEndpoint", + value=endpoint, + description="MCP invocation endpoint registered with DevOps Agent", + ) diff --git a/mcp/saas-status-mcp/infrastructure/cdk/requirements.txt b/mcp/saas-status-mcp/infrastructure/cdk/requirements.txt new file mode 100644 index 0000000..e7b88ea --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/cdk/requirements.txt @@ -0,0 +1,7 @@ +# aws-cdk-lib >= 2.251.0 is required for typed DevOps Agent MCP (SigV4) constructs: +# - CfnService (register the MCP server) [added by 2.247.0] +# - CfnAssociation ServiceConfiguration.mcp_server_sig_v4 [added by 2.251.0] +# Earlier versions lack the mcp_server_sig_v4 association field, so the CDK-based +# DevOps Agent registration path will not synthesize on them. +aws-cdk-lib>=2.251.0 +constructs>=10.0.0 diff --git a/mcp/saas-status-mcp/infrastructure/cdk/stack.py b/mcp/saas-status-mcp/infrastructure/cdk/stack.py new file mode 100644 index 0000000..5ab8f61 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/cdk/stack.py @@ -0,0 +1,159 @@ +"""CDK Stack for SaaS Status MCP Server. + +Deploys: +- S3 bucket for deployment package +- IAM role for AgentCore Runtime +- AgentCore Runtime (CfnRuntime) with MCP protocol, PUBLIC network +- CloudWatch Log Group for structured logging +""" + +import aws_cdk as cdk +from aws_cdk import ( + Stack, + aws_bedrockagentcore as bedrockagentcore, + aws_iam as iam, + aws_logs as logs, + aws_s3 as s3, + CfnOutput, + RemovalPolicy, +) +from constructs import Construct + + +class SaasStatusMcpStack(Stack): + """Stack for the SaaS Status MCP Server hosted on AgentCore Runtime.""" + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + region = cdk.Aws.REGION + account = cdk.Aws.ACCOUNT_ID + + # S3 bucket for deployment package (created by deploy script before CDK runs) + bucket_name = f"saas-status-mcp-{account}-{region}" + deployment_bucket = s3.Bucket.from_bucket_name( + self, "DeploymentBucket", bucket_name + ) + + # CloudWatch Log Group for server logs + log_group = logs.LogGroup( + self, + "SaasStatusMcpLogs", + retention=logs.RetentionDays.TWO_WEEKS, + removal_policy=RemovalPolicy.DESTROY, + ) + + # IAM role for AgentCore Runtime + runtime_role = iam.Role( + self, + "SaasStatusMcpRuntimeRole", + assumed_by=iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), + description="IAM role for SaaS Status MCP Server on AgentCore Runtime", + ) + + # CloudWatch Logs permissions + runtime_role.add_to_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources=[ + f"arn:aws:logs:{region}:{account}:log-group:/aws/bedrock-agentcore/runtimes/*", + ], + ) + ) + + # S3 read access for deployment package + runtime_role.add_to_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["s3:GetObject"], + resources=[f"{deployment_bucket.bucket_arn}/*"], + ) + ) + + # AgentCore Runtime — MCP server + runtime = bedrockagentcore.CfnRuntime( + self, + "SaasStatusMcpRuntime", + agent_runtime_name="saas_status_mcp", + description="SaaS Status MCP Server — queries Statuspage.io APIs for upstream dependency checks", + role_arn=runtime_role.role_arn, + agent_runtime_artifact={ + "codeConfiguration": { + "code": { + "s3": { + "bucket": deployment_bucket.bucket_name, + "prefix": "agent/deployment_package.zip", + }, + }, + "entryPoint": ["main.py"], + "runtime": "PYTHON_3_13", + }, + }, + network_configuration={"networkMode": "PUBLIC"}, + protocol_configuration="MCP", + environment_variables={ + "LOG_LEVEL": "INFO", + # S3-backed provider registry (conditional GET, no redeploy to update) + "PROVIDERS_BUCKET": bucket_name, + "PROVIDERS_KEY": "config/providers.json", + "PROVIDERS_POLL_INTERVAL": "60", + }, + ) + + # Outputs + CfnOutput( + self, + "RuntimeArn", + value=runtime.attr_agent_runtime_arn, + description="AgentCore Runtime ARN for the MCP server", + # Exported so the registration stack can import it cross-stack without + # needing a CLI lookup (the registration stack may target a different region, + # but the export name is globally unique and Fn.import_value resolves it). + export_name=f"SaasStatusMcp-RuntimeArn-{region}", + ) + + # MCP invocation endpoint: the runtime ARN is URL-encoded and placed in the + # path, with the DEFAULT qualifier. This is the URL registered in DevOps Agent + # (SigV4) and used by the local proxy. `:` -> %3A and `/` -> %2F. + encoded_arn = cdk.Fn.join( + "%2F", + cdk.Fn.split( + "/", + cdk.Fn.join( + "%3A", + cdk.Fn.split(":", runtime.attr_agent_runtime_arn), + ), + ), + ) + CfnOutput( + self, + "RuntimeEndpoint", + value=f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT", + description="MCP invocation endpoint URL (register this in DevOps Agent SigV4 config)", + ) + + CfnOutput( + self, + "RuntimeRoleArn", + value=runtime_role.role_arn, + description="IAM role ARN for AgentCore Runtime", + ) + + CfnOutput( + self, + "DeploymentBucketName", + value=deployment_bucket.bucket_name, + description="S3 bucket for deployment packages", + ) + + CfnOutput( + self, + "LogGroupName", + value=log_group.log_group_name, + description="CloudWatch Log Group for server logs", + ) diff --git a/mcp/saas-status-mcp/infrastructure/terraform/main.tf b/mcp/saas-status-mcp/infrastructure/terraform/main.tf new file mode 100644 index 0000000..8e6293e --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/terraform/main.tf @@ -0,0 +1,168 @@ +# ============================================================================= +# main.tf - SaaS Status MCP Server: AgentCore Runtime Stack +# ============================================================================= +# Deploys the AgentCore Runtime and its supporting resources in the runtime +# region. +# +# NOTE: The S3 bucket is created by the deploy script BEFORE terraform apply +# (so the zip upload succeeds first). Terraform reads it via a data source +# rather than managing it - this avoids a double-create conflict and keeps +# the bucket alive across terraform destroy cycles. +# +# Deployed to: var.runtime_region +# ============================================================================= + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + awscc = { + source = "hashicorp/awscc" + version = "~> 1.91" + } + # Used for IAM propagation delay before AgentCore runtime creation + time = { + source = "hashicorp/time" + version = "~> 0.9" + } + } +} + +# -- Providers ---------------------------------------------------------------- + +provider "aws" { + region = var.runtime_region +} + +provider "awscc" { + region = var.runtime_region +} + +# -- Locals ------------------------------------------------------------------- + +locals { + bucket_name = "saas-status-mcp-${var.account_id}-${var.runtime_region}" + + runtime_endpoint = "https://bedrock-agentcore.${var.runtime_region}.amazonaws.com/runtimes/${replace(replace(awscc_bedrockagentcore_runtime.mcp.agent_runtime_arn, ":", "%3A"), "/", "%2F")}/invocations?qualifier=DEFAULT" +} + +# -- S3 bucket (read-only reference) ----------------------------------------- +# Created by the deploy script before terraform apply. Terraform reads it +# here rather than owning it to avoid double-create conflicts. + +data "aws_s3_bucket" "runtime" { + bucket = local.bucket_name +} + +# -- CloudWatch log group ----------------------------------------------------- + +resource "aws_cloudwatch_log_group" "runtime" { + name = "/aws/bedrock-agentcore/${var.runtime_name}" + retention_in_days = var.log_retention_days + + tags = { + Project = "saas-status-mcp" + ManagedBy = "terraform" + } +} + +# -- IAM role for the AgentCore Runtime --------------------------------------- + +resource "aws_iam_role" "runtime" { + name = "SaasStatusMcpRuntimeRole-${var.runtime_region}" + description = "Execution role for the SaaS Status MCP AgentCore Runtime" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "bedrock-agentcore.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { + Project = "saas-status-mcp" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role_policy" "runtime" { + name = "SaasStatusMcpRuntimePolicy" + role = aws_iam_role.runtime.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadDeploymentZip" + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = [ + "${data.aws_s3_bucket.runtime.arn}/agent/*", + "${data.aws_s3_bucket.runtime.arn}/config/*", + ] + }, + { + Sid = "WriteLogs" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + Resource = "${aws_cloudwatch_log_group.runtime.arn}:*" + } + ] + }) +} + +# IAM propagation delay — AgentCore validates the role at create time, +# so we must wait for global IAM consistency before creating the runtime. +resource "time_sleep" "iam_propagation" { + create_duration = "20s" + depends_on = [aws_iam_role_policy.runtime] +} + +# -- AgentCore Runtime -------------------------------------------------------- + +resource "awscc_bedrockagentcore_runtime" "mcp" { + agent_runtime_name = var.runtime_name + description = "SaaS status MCP server for AWS DevOps Agent" + role_arn = aws_iam_role.runtime.arn + protocol_configuration = "MCP" + + agent_runtime_artifact = { + code_configuration = { + runtime = "PYTHON_3_13" + entry_point = ["main.py"] + code = { + s3 = { + bucket = data.aws_s3_bucket.runtime.id + prefix = "agent/deployment_package.zip" + } + } + } + } + + network_configuration = { + network_mode = "PUBLIC" + } + + environment_variables = { + PROVIDERS_BUCKET = data.aws_s3_bucket.runtime.id + PROVIDERS_KEY = "config/providers.json" + PROVIDERS_POLL_INTERVAL = tostring(var.providers_poll_interval) + } + + tags = { + Project = "saas-status-mcp" + ManagedBy = "terraform" + } + + depends_on = [time_sleep.iam_propagation] +} diff --git a/mcp/saas-status-mcp/infrastructure/terraform/outputs.tf b/mcp/saas-status-mcp/infrastructure/terraform/outputs.tf new file mode 100644 index 0000000..e7032d5 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/terraform/outputs.tf @@ -0,0 +1,42 @@ +# ============================================================================= +# outputs.tf - SaaS Status MCP Server (Terraform) +# ============================================================================= + +# -- Runtime ------------------------------------------------------------------ + +output "runtime_arn" { + description = "AgentCore Runtime ARN." + value = awscc_bedrockagentcore_runtime.mcp.agent_runtime_arn +} + +output "runtime_endpoint" { + description = "MCP invocation endpoint (percent-encoded ARN, used by DevOps Agent and local proxy)." + value = local.runtime_endpoint +} + +output "runtime_role_arn" { + description = "IAM role assumed by the AgentCore Runtime." + value = aws_iam_role.runtime.arn +} + +output "s3_bucket" { + description = "S3 bucket holding the deployment zip and provider registry." + value = data.aws_s3_bucket.runtime.id +} + +output "log_group" { + description = "CloudWatch log group for runtime logs." + value = aws_cloudwatch_log_group.runtime.name +} + +# -- Registration (only populated when agent_space_arn is set) ---------------- + +output "service_id" { + description = "DevOps Agent Service ID (null if registration was skipped)." + value = local.register ? awscc_devopsagent_service.mcp[0].service_id : null +} + +output "signing_role_arn" { + description = "SigV4 signing role ARN (null if registration was skipped)." + value = local.register ? aws_iam_role.signing[0].arn : null +} diff --git a/mcp/saas-status-mcp/infrastructure/terraform/registration.tf b/mcp/saas-status-mcp/infrastructure/terraform/registration.tf new file mode 100644 index 0000000..ac5dc21 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/terraform/registration.tf @@ -0,0 +1,130 @@ +# ============================================================================= +# registration.tf - SaaS Status MCP Server: DevOps Agent Registration +# ============================================================================= +# Registers the deployed AgentCore Runtime as an MCP tool source on a DevOps +# Agent Space. This file is a no-op when agent_space_arn is empty. +# +# Resources are deployed to the Agent Space's region, which is parsed from +# the agent_space_arn variable and may differ from the runtime region. +# ============================================================================= + +locals { + register = var.agent_space_arn != "" + + space_region = local.register ? regex( + "^arn:aws[a-z-]*:aidevops:([^:]+):[0-9]+:agentspace/.+$", + var.agent_space_arn + )[0] : "" + + space_id = local.register ? regex( + "^arn:aws[a-z-]*:aidevops:[^:]+:[0-9]+:agentspace/(.+)$", + var.agent_space_arn + )[0] : "" +} + +provider "aws" { + alias = "space" + region = local.space_region != "" ? local.space_region : var.runtime_region +} + +provider "awscc" { + alias = "space" + region = local.space_region != "" ? local.space_region : var.runtime_region +} + +resource "aws_iam_role" "signing" { + count = local.register ? 1 : 0 + + provider = aws.space + name = "SaasStatusMcpSigningRole-${local.space_region}" + description = "SigV4 signing role - DevOps Agent assumes this to invoke the SaaS Status MCP runtime" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "aidevops.amazonaws.com" } + Action = "sts:AssumeRole" + Condition = { + StringEquals = { "aws:SourceAccount" = var.account_id } + ArnLike = { + "aws:SourceArn" = "arn:aws:aidevops:${local.space_region}:${var.account_id}:service/*" + } + } + }] + }) + + tags = { + Project = "saas-status-mcp" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role_policy" "signing" { + count = local.register ? 1 : 0 + + provider = aws.space + name = "InvokeSaasStatusMcpRuntime" + role = aws_iam_role.signing[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "bedrock-agentcore:InvokeAgentRuntime" + Resource = [ + awscc_bedrockagentcore_runtime.mcp.agent_runtime_arn, + "${awscc_bedrockagentcore_runtime.mcp.agent_runtime_arn}/*", + ] + }] + }) +} + +# IAM propagation delay — DevOps Agent service validates the signing role at +# create time, so we must wait for global IAM consistency first. +resource "time_sleep" "signing_iam_propagation" { + count = local.register ? 1 : 0 + create_duration = "15s" + depends_on = [aws_iam_role_policy.signing] +} + +resource "awscc_devopsagent_service" "mcp" { + count = local.register ? 1 : 0 + + provider = awscc.space + service_type = "mcpserversigv4" + + service_details = { + mcp_server_sig_v4 = { + name = var.service_name + endpoint = local.runtime_endpoint + description = "SaaS status pages (Statuspage.io) for upstream dependency checks" + authorization_config = { + region = var.runtime_region + service = "bedrock-agentcore" + role_arn = aws_iam_role.signing[0].arn + } + } + } + + depends_on = [time_sleep.signing_iam_propagation] +} + +resource "awscc_devopsagent_association" "mcp" { + count = local.register ? 1 : 0 + + provider = awscc.space + agent_space_id = local.space_id + service_id = awscc_devopsagent_service.mcp[0].service_id + + configuration = { + mcp_server_sig_v4 = { + tools = [ + "list_providers", + "get_service_status", + "get_active_events", + "check_all_dependencies", + ] + } + } +} diff --git a/mcp/saas-status-mcp/infrastructure/terraform/terraform.tfvars.example b/mcp/saas-status-mcp/infrastructure/terraform/terraform.tfvars.example new file mode 100644 index 0000000..5596480 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/terraform/terraform.tfvars.example @@ -0,0 +1,31 @@ +# ============================================================================= +# terraform.tfvars.example - SaaS Status MCP Server +# ============================================================================= +# Copy this file to terraform.tfvars and fill in your values. +# deploy-all-terraform.ps1 generates terraform.tfvars automatically. + +# -- Required ----------------------------------------------------------------- + +# Region where the AgentCore Runtime will be deployed. +runtime_region = "eu-west-3" + +# Your AWS account ID (12-digit number). +account_id = "123456789012" + +# -- Optional ----------------------------------------------------------------- + +# Name shown in the DevOps Agent console (default: saas-status-mcp). +# service_name = "saas-status-mcp" + +# CloudWatch log retention in days (default: 14). +# log_retention_days = 14 + +# Seconds between provider registry refreshes from S3 (default: 60). +# providers_poll_interval = 60 + +# -- DevOps Agent registration ------------------------------------------------ +# Paste your Agent Space ARN to register the MCP server automatically. +# Open the DevOps Agent console and from your space click Actions > Copy ARN. +# Leave empty (or commented out) to skip registration. +# +# agent_space_arn = "arn:aws:aidevops:eu-west-1:123456789012:agentspace/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" diff --git a/mcp/saas-status-mcp/infrastructure/terraform/variables.tf b/mcp/saas-status-mcp/infrastructure/terraform/variables.tf new file mode 100644 index 0000000..72d52d6 --- /dev/null +++ b/mcp/saas-status-mcp/infrastructure/terraform/variables.tf @@ -0,0 +1,72 @@ +# ============================================================================= +# Variables - SaaS Status MCP Server (Terraform) +# ============================================================================= +# Copy terraform.tfvars.example to terraform.tfvars and fill in your values. +# The deploy-all-terraform.ps1 script handles this automatically. + +# -- Runtime (AgentCore) ------------------------------------------------------ + +variable "runtime_region" { + description = "AWS region where the AgentCore Runtime is deployed (e.g. eu-west-3)." + type = string +} + +variable "account_id" { + description = "AWS account ID. Auto-detected by deploy-all-terraform.ps1." + type = string +} + +variable "service_name" { + description = "Name for the MCP server as it appears in the DevOps Agent console." + type = string + default = "saas-status-mcp" + + validation { + condition = can(regex("^[a-zA-Z0-9_-]+$", var.service_name)) + error_message = "service_name must match [a-zA-Z0-9_-]+." + } +} + +variable "runtime_name" { + description = "AgentCore Runtime name — must match [a-zA-Z][a-zA-Z0-9_]{0,47} (no hyphens)." + type = string + default = "saas_status_mcp" + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]{0,47}$", var.runtime_name)) + error_message = "runtime_name must match [a-zA-Z][a-zA-Z0-9_]{0,47} — no hyphens allowed by AgentCore." + } +} + +variable "log_retention_days" { + description = "CloudWatch log retention in days for the runtime." + type = number + default = 14 +} + +variable "providers_poll_interval" { + description = "Seconds between S3 conditional-GET polls for the provider registry." + type = number + default = 60 +} + +# -- DevOps Agent registration (optional) ------------------------------------- +# Leave agent_space_arn empty to skip registration (runtime-only deploy). + +variable "agent_space_arn" { + description = <<-EOT + Full ARN of the DevOps Agent Space to register the MCP server against. + Example: arn:aws:aidevops:eu-west-1:123456789012:agentspace/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + Leave empty to skip DevOps Agent registration. + EOT + type = string + default = "" + + validation { + condition = ( + var.agent_space_arn == "" || + can(regex("^arn:aws[a-z-]*:aidevops:[a-z0-9-]+:[0-9]+:agentspace/.+$", var.agent_space_arn)) + ) + error_message = "agent_space_arn must be empty or a valid Agent Space ARN." + } +} diff --git a/mcp/saas-status-mcp/local-proxy/proxy.py b/mcp/saas-status-mcp/local-proxy/proxy.py new file mode 100644 index 0000000..f3ad532 --- /dev/null +++ b/mcp/saas-status-mcp/local-proxy/proxy.py @@ -0,0 +1,114 @@ +"""Local SigV4 bridge for testing the SaaS Status MCP server from Kiro. + +The MCP server is hosted on AgentCore Runtime behind IAM (SigV4) auth, so a +local MCP client like Kiro can't reach it directly. This stdio MCP server runs +on your machine, re-exposes the same 4 tools, and forwards each call to the +deployed runtime via invoke_agent_runtime (boto3 signs the request with your +AWS credentials). + +Kiro launches this as a normal stdio MCP server. Configure via env vars: + SAAS_MCP_RUNTIME_ARN (required) — the AgentCore runtime ARN + AWS_REGION (required) — region the runtime lives in + AWS_PROFILE (optional) — AWS credentials profile to use +""" + +import json +import os +import sys + +import boto3 +from mcp.server.fastmcp import FastMCP + +RUNTIME_ARN = os.environ.get("SAAS_MCP_RUNTIME_ARN") +REGION = os.environ.get("AWS_REGION") + +if not RUNTIME_ARN or not REGION: + print( + "ERROR: set SAAS_MCP_RUNTIME_ARN and AWS_REGION environment variables.", + file=sys.stderr, + ) + sys.exit(1) + +_client = boto3.client("bedrock-agentcore", region_name=REGION) + +# Local stdio MCP server that Kiro connects to +mcp = FastMCP("saas-status-mcp-proxy") + +_rpc_id = 0 + + +def _forward(tool_name: str, arguments: dict) -> dict: + """Forward a tools/call to the remote AgentCore MCP runtime and unwrap the result.""" + global _rpc_id + _rpc_id += 1 + + rpc_message = { + "jsonrpc": "2.0", + "method": "tools/call", + "id": _rpc_id, + "params": {"name": tool_name, "arguments": arguments}, + } + + response = _client.invoke_agent_runtime( + agentRuntimeArn=RUNTIME_ARN, + payload=json.dumps(rpc_message).encode("utf-8"), + contentType="application/json", + accept="application/json, text/event-stream", + ) + + body = response["response"].read().decode("utf-8") + if body.startswith("data:"): + body = body.split("data:", 1)[1].strip() + + parsed = json.loads(body) + result = parsed.get("result", {}) + + # MCP tool results wrap the payload as content[].text (a JSON string) + content = result.get("content", []) + if content and content[0].get("type") == "text": + try: + return json.loads(content[0]["text"]) + except json.JSONDecodeError: + return {"text": content[0]["text"]} + return result + + +@mcp.tool() +def list_providers() -> dict: + """List all configured SaaS providers (name, display name, status page URL).""" + return _forward("list_providers", {}) + + +@mcp.tool() +def get_service_status(provider: str) -> dict: + """Get the current overall operational status for a SaaS provider. + + Args: + provider: Provider name (e.g. "snowflake", "datadog", "mongodb"). + """ + return _forward("get_service_status", {"provider": provider}) + + +@mcp.tool() +def get_active_events(provider: str, include_history: bool = False) -> dict: + """Get all active events (unresolved incidents + active maintenances) for a provider. + + Args: + provider: Provider name (e.g. "snowflake", "datadog", "mongodb"). + include_history: If true, include full update history per event. Default false. + """ + return _forward("get_active_events", {"provider": provider, "include_history": include_history}) + + +@mcp.tool() +def check_all_dependencies(providers: list[str]) -> dict: + """Bulk-check status and active events across multiple providers (max 10) in parallel. + + Args: + providers: List of provider names to check. + """ + return _forward("check_all_dependencies", {"providers": providers}) + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/mcp/saas-status-mcp/local-proxy/requirements.txt b/mcp/saas-status-mcp/local-proxy/requirements.txt new file mode 100644 index 0000000..0e657fa --- /dev/null +++ b/mcp/saas-status-mcp/local-proxy/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.10.0 +boto3>=1.38.0 diff --git a/mcp/saas-status-mcp/local-proxy/smoke_test.py b/mcp/saas-status-mcp/local-proxy/smoke_test.py new file mode 100644 index 0000000..66fc6de --- /dev/null +++ b/mcp/saas-status-mcp/local-proxy/smoke_test.py @@ -0,0 +1,50 @@ +"""Smoke test: spawn proxy.py as a stdio MCP server and call the tools. + +Simulates what Kiro does — launches the proxy, runs the MCP handshake over +stdio, lists tools, and calls one. Confirms the local bridge works before +wiring it into Kiro's mcp.json. +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Replace with your deployed runtime ARN (see the RuntimeArn stack output). +RUNTIME_ARN = "arn:aws:bedrock-agentcore:eu-west-3:123456789012:runtime/saas_status_mcp-XXXXXXXXXX" +REGION = "eu-west-3" + + +async def main(): + env = dict(os.environ) + env["SAAS_MCP_RUNTIME_ARN"] = RUNTIME_ARN + env["AWS_REGION"] = REGION + + params = StdioServerParameters( + command="python", + args=["proxy.py"], + env=env, + ) + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + print("--- tools/list") + tools = await session.list_tools() + for t in tools.tools: + print(f" * {t.name}") + print() + + print("--- list_providers (should show S3-backed registry count)") + result = await session.call_tool("list_providers", {}) + import json as _json + for c in result.content: + data = _json.loads(c.text) + providers = data.get("providers", []) + print(f" {len(providers)} providers: {', '.join(p['name'] for p in providers)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mcp/saas-status-mcp/scripts/refresh-providers.ps1 b/mcp/saas-status-mcp/scripts/refresh-providers.ps1 new file mode 100644 index 0000000..9906777 --- /dev/null +++ b/mcp/saas-status-mcp/scripts/refresh-providers.ps1 @@ -0,0 +1,30 @@ +# refresh-providers.ps1 — Update the live provider registry with NO redeploy. +# +# The running MCP server reads providers.json from S3 via a conditional GET, +# so pushing a new version of the file is all it takes. The server picks up +# the change within one poll interval (default 60s). No zip, no CDK, no restart. +# +# Usage: edit agent/providers.json, then run: .\refresh-providers.ps1 + +param() + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path "$ScriptDir/../../..").Path + +& "$repoRoot/shared/scripts/check-prerequisites.ps1" | Out-Null +$region = $global:AWS_REGION +$account = (aws sts get-caller-identity --query "Account" --output text) + +$bucketName = "saas-status-mcp-$account-$region" +$key = "config/providers.json" + +Write-Host "Uploading agent/providers.json to s3://$bucketName/$key ..." -ForegroundColor Yellow +aws s3 cp "$ScriptDir/../agent/providers.json" "s3://$bucketName/$key" --quiet + +$count = (Get-Content "$ScriptDir/../agent/providers.json" | ConvertFrom-Json).providers.Count +Write-Host "" +Write-Host "Done. $count providers published to the live registry." -ForegroundColor Green +Write-Host "The running MCP server will pick up the change within its poll interval (~60s)." -ForegroundColor Cyan +Write-Host "No redeploy required." -ForegroundColor Cyan diff --git a/mcp/saas-status-mcp/scripts/refresh-providers.sh b/mcp/saas-status-mcp/scripts/refresh-providers.sh new file mode 100644 index 0000000..2ae0af2 --- /dev/null +++ b/mcp/saas-status-mcp/scripts/refresh-providers.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# refresh-providers.sh — Update the live provider registry with NO redeploy. +# +# The running MCP server reads providers.json from S3 via a conditional GET, +# so pushing a new version of the file is all it takes. The server picks up +# the change within one poll interval (default 60s). No zip, no CDK, no restart. +# +# Usage: edit agent/providers.json, then run: ./refresh-providers.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +source "${REPO_ROOT}/shared/scripts/check-prerequisites.sh" > /dev/null +REGION="${AWS_REGION}" +ACCOUNT=$(aws sts get-caller-identity --query "Account" --output text) + +BUCKET_NAME="saas-status-mcp-${ACCOUNT}-${REGION}" +KEY="config/providers.json" + +echo "Uploading agent/providers.json to s3://${BUCKET_NAME}/${KEY} ..." +aws s3 cp "${SCRIPT_DIR}/../agent/providers.json" "s3://${BUCKET_NAME}/${KEY}" --quiet + +echo "" +echo "Done. Provider registry published to the live server." +echo "The running MCP server will pick up the change within its poll interval (~60s)." +echo "No redeploy required." diff --git a/mcp/saas-status-mcp/scripts/setup-devops-agent.ps1 b/mcp/saas-status-mcp/scripts/setup-devops-agent.ps1 new file mode 100644 index 0000000..e06570c --- /dev/null +++ b/mcp/saas-status-mcp/scripts/setup-devops-agent.ps1 @@ -0,0 +1,162 @@ +# ============================================================================= +# Setup DevOps Agent MCP Registration (PowerShell) +# ============================================================================= +# Deploys SaasStatusMcpRegistrationStack to your Agent Space's region using CDK. +# The stack creates the SigV4 signing IAM role, registers the MCP server +# (AWS::DevOpsAgent::Service), and attaches it to your Agent Space with the +# four tools (AWS::DevOpsAgent::Association). +# +# Requires aws-cdk-lib >= 2.251.0 (see infrastructure/cdk/requirements.txt). +# +# Usage: +# .\scripts\setup-devops-agent.ps1 +# .\scripts\setup-devops-agent.ps1 -AgentSpaceArn arn:aws:aidevops:eu-west-1::agentspace/ +# .\scripts\setup-devops-agent.ps1 -AgentSpaceArn -RuntimeRegion eu-west-3 +# ============================================================================= + +param( + [string]$AgentSpaceArn = "", + [string]$RuntimeRegion = "" +) + +$ErrorActionPreference = "Stop" +$env:AWS_PAGER = "" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path "$ScriptDir/../../..").Path +$CdkDir = "$ScriptDir/../infrastructure/cdk" + +Write-Host "==============================================" -ForegroundColor Cyan +Write-Host " DevOps Agent MCP Registration (CDK)" -ForegroundColor Cyan +Write-Host "==============================================" -ForegroundColor Cyan +Write-Host "" + +# --------------------------------------------------------------------------- +# Resolve caller identity +# --------------------------------------------------------------------------- +$AwsAccountId = (aws sts get-caller-identity --query Account --output text) +if (-not $AwsAccountId) { + Write-Host "ERROR: could not resolve AWS account. Configure credentials first." -ForegroundColor Red + exit 1 +} + +# --------------------------------------------------------------------------- +# Runtime region — where SaasStatusMcpStack was deployed +# --------------------------------------------------------------------------- +if (-not $RuntimeRegion) { + $RuntimeRegion = if ($env:AWS_REGION) { $env:AWS_REGION } ` + elseif ($env:AWS_DEFAULT_REGION) { $env:AWS_DEFAULT_REGION } ` + else { (aws configure get region 2>$null) } +} +if (-not $RuntimeRegion) { + $RuntimeRegion = Read-Host "Enter the region where the MCP runtime is deployed (e.g. eu-west-3)" +} + +# --------------------------------------------------------------------------- +# Fetch the runtime ARN from the main stack outputs (one CLI call, no manual input) +# --------------------------------------------------------------------------- +Write-Host "[1/3] Reading runtime ARN from CloudFormation stack..." -ForegroundColor Yellow + +$MainStackName = "SaasStatusMcpStack-$RuntimeRegion" +$RuntimeArn = aws cloudformation describe-stacks ` + --stack-name $MainStackName ` + --region $RuntimeRegion ` + --query "Stacks[0].Outputs[?OutputKey=='RuntimeArn'].OutputValue" ` + --output text 2>$null + +if (-not $RuntimeArn -or $RuntimeArn -eq "None") { + Write-Host " ERROR: stack '$MainStackName' not found in $RuntimeRegion." -ForegroundColor Red + Write-Host " Deploy the MCP server first: .\deploy-all.ps1" -ForegroundColor Red + exit 1 +} +Write-Host " Runtime ARN: $RuntimeArn" +Write-Host "" + +# --------------------------------------------------------------------------- +# Agent Space ARN — carries the region so the user doesn't specify it separately +# --------------------------------------------------------------------------- +if (-not $AgentSpaceArn) { + $AgentSpaceArn = if ($env:AGENT_SPACE_ARN) { $env:AGENT_SPACE_ARN } else { "" } +} +if (-not $AgentSpaceArn) { + Write-Host "Provide your Agent Space ARN (open the DevOps Agent console and from your space click Actions \ Copy ARN)." -ForegroundColor Gray + Write-Host " Example: arn:aws:aidevops:eu-west-1:${AwsAccountId}:agentspace/xxxxxxxx-xxxx-..." -ForegroundColor Gray + $AgentSpaceArn = Read-Host "Enter your Agent Space ARN" +} + +# Parse region + ID from the ARN — avoids asking for them separately +if ($AgentSpaceArn -match '^arn:aws[\w-]*:aidevops:([^:]+):(\d+):agentspace/(.+)$') { + $AgentSpaceRegion = $Matches[1] + $AgentSpaceAccount = $Matches[2] + $AgentSpaceId = $Matches[3] +} else { + Write-Host "ERROR: not a valid Agent Space ARN." -ForegroundColor Red + Write-Host " Expected: arn:aws:aidevops:::agentspace/" -ForegroundColor Red + exit 1 +} +if ($AgentSpaceAccount -ne $AwsAccountId) { + Write-Host "WARNING: Agent Space account ($AgentSpaceAccount) differs from your credentials ($AwsAccountId)." -ForegroundColor Yellow +} + +Write-Host " Account: $AwsAccountId" +Write-Host " Runtime region: $RuntimeRegion (SigV4 signing region)" +Write-Host " Agent Space region: $AgentSpaceRegion" +Write-Host " Agent Space ID: $AgentSpaceId" +Write-Host "" + +# --------------------------------------------------------------------------- +# CDK deploy the registration stack +# --------------------------------------------------------------------------- +Write-Host "[2/3] Installing CDK dependencies..." -ForegroundColor Yellow +Push-Location $CdkDir +python -m pip install -r requirements.txt --quiet 2>$null +Pop-Location + +Write-Host "[3/3] Deploying SaasStatusMcpRegistrationStack via CDK..." -ForegroundColor Yellow +Write-Host " (creates IAM role, DevOps Agent Service, Association)" -ForegroundColor Gray +Write-Host "" + +$StackId = "SaasStatusMcpRegistrationStack-$AgentSpaceRegion" +$env:PYTHONPATH = $RepoRoot + +Push-Location $CdkDir +npx cdk deploy $StackId ` + --require-approval never ` + --context "agent_space_id=$AgentSpaceId" ` + --context "agent_space_region=$AgentSpaceRegion" ` + --context "runtime_arn=$RuntimeArn" ` + --context "runtime_region=$RuntimeRegion" +$exitCode = $LASTEXITCODE +Pop-Location + +if ($exitCode -ne 0) { + Write-Host "" + Write-Host "ERROR: CDK deployment failed. See errors above." -ForegroundColor Red + exit 1 +} + +# --------------------------------------------------------------------------- +# Read outputs from the deployed registration stack +# --------------------------------------------------------------------------- +$regOutputs = aws cloudformation describe-stacks ` + --stack-name $StackId ` + --region $AgentSpaceRegion ` + --query "Stacks[0].Outputs" ` + --output json 2>$null | ConvertFrom-Json + +$serviceId = ($regOutputs | Where-Object { $_.OutputKey -eq "ServiceId" }).OutputValue +$signingRole = ($regOutputs | Where-Object { $_.OutputKey -eq "SigningRoleArn" }).OutputValue + +Write-Host "" +Write-Host "==============================================" -ForegroundColor Green +Write-Host " Registration Complete" -ForegroundColor Green +Write-Host "==============================================" -ForegroundColor Green +Write-Host "" +Write-Host " CDK stack: $StackId" -ForegroundColor Cyan +Write-Host " Service ID: $serviceId" -ForegroundColor Cyan +Write-Host " Agent Space: $AgentSpaceId ($AgentSpaceRegion)" -ForegroundColor Cyan +Write-Host " Signing role: $signingRole" -ForegroundColor Cyan +Write-Host " MCP name: saas-status-mcp" -ForegroundColor Cyan +Write-Host " Tools: 4 enabled" -ForegroundColor Cyan +Write-Host "" +exit 0 diff --git a/mcp/saas-status-mcp/scripts/setup-devops-agent.sh b/mcp/saas-status-mcp/scripts/setup-devops-agent.sh new file mode 100644 index 0000000..7c70784 --- /dev/null +++ b/mcp/saas-status-mcp/scripts/setup-devops-agent.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# ============================================================================= +# Setup DevOps Agent MCP Registration (Bash) +# ============================================================================= +# Deploys SaasStatusMcpRegistrationStack to your Agent Space's region using CDK. +# The stack creates the SigV4 signing IAM role, registers the MCP server +# (AWS::DevOpsAgent::Service), and attaches it to your Agent Space with the +# four tools (AWS::DevOpsAgent::Association). +# +# Requires aws-cdk-lib >= 2.251.0 (see infrastructure/cdk/requirements.txt). +# +# Usage: +# ./scripts/setup-devops-agent.sh +# AGENT_SPACE_ARN=arn:aws:aidevops:eu-west-1::agentspace/ ./scripts/setup-devops-agent.sh +# AGENT_SPACE_ARN= RUNTIME_REGION=eu-west-3 ./scripts/setup-devops-agent.sh +# ============================================================================= + +set -euo pipefail +export AWS_PAGER="" + +AGENT_SPACE_ARN="${AGENT_SPACE_ARN:-}" +RUNTIME_REGION="${RUNTIME_REGION:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +CDK_DIR="${SCRIPT_DIR}/../infrastructure/cdk" + +echo "==============================================" +echo " DevOps Agent MCP Registration (CDK)" +echo "==============================================" +echo "" + +# --------------------------------------------------------------------------- +# Resolve caller identity +# --------------------------------------------------------------------------- +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +if [ -z "${AWS_ACCOUNT_ID}" ]; then + echo "ERROR: could not resolve AWS account. Configure credentials first." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Runtime region — where SaasStatusMcpStack was deployed +# --------------------------------------------------------------------------- +if [ -z "${RUNTIME_REGION}" ]; then + RUNTIME_REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region 2>/dev/null || echo '')}}" +fi +if [ -z "${RUNTIME_REGION}" ]; then + read -r -p "Enter the region where the MCP runtime is deployed (e.g. eu-west-3): " RUNTIME_REGION +fi + +# --------------------------------------------------------------------------- +# Fetch the runtime ARN from the main stack outputs (one CLI call, no manual input) +# --------------------------------------------------------------------------- +echo "[1/3] Reading runtime ARN from CloudFormation stack..." + +MAIN_STACK_NAME="SaasStatusMcpStack-${RUNTIME_REGION}" +RUNTIME_ARN=$(aws cloudformation describe-stacks \ + --stack-name "${MAIN_STACK_NAME}" \ + --region "${RUNTIME_REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='RuntimeArn'].OutputValue" \ + --output text 2>/dev/null || echo "") + +if [ -z "${RUNTIME_ARN}" ] || [ "${RUNTIME_ARN}" = "None" ]; then + echo " ERROR: stack '${MAIN_STACK_NAME}' not found in ${RUNTIME_REGION}." + echo " Deploy the MCP server first: ./deploy-all.sh" + exit 1 +fi +echo " Runtime ARN: ${RUNTIME_ARN}" +echo "" + +# --------------------------------------------------------------------------- +# Agent Space ARN — carries the region so the user doesn't specify it separately +# --------------------------------------------------------------------------- +if [ -z "${AGENT_SPACE_ARN}" ]; then + echo "Provide your Agent Space ARN (open the DevOps Agent console and from your space click Actions > Copy ARN)." + echo " Example: arn:aws:aidevops:eu-west-1:${AWS_ACCOUNT_ID}:agentspace/xxxxxxxx-xxxx-..." + read -r -p "Enter your Agent Space ARN: " AGENT_SPACE_ARN +fi + +# Parse region + ID from the ARN — avoids asking for them separately +if [[ "${AGENT_SPACE_ARN}" =~ ^arn:aws[a-z-]*:aidevops:([^:]+):([0-9]+):agentspace/(.+)$ ]]; then + AGENT_SPACE_REGION="${BASH_REMATCH[1]}" + AGENT_SPACE_ACCOUNT="${BASH_REMATCH[2]}" + AGENT_SPACE_ID="${BASH_REMATCH[3]}" +else + echo "ERROR: not a valid Agent Space ARN." + echo " Expected: arn:aws:aidevops:::agentspace/" + exit 1 +fi +if [ "${AGENT_SPACE_ACCOUNT}" != "${AWS_ACCOUNT_ID}" ]; then + echo "WARNING: Agent Space account (${AGENT_SPACE_ACCOUNT}) differs from your credentials (${AWS_ACCOUNT_ID})." +fi + +echo " Account: ${AWS_ACCOUNT_ID}" +echo " Runtime region: ${RUNTIME_REGION} (SigV4 signing region)" +echo " Agent Space region: ${AGENT_SPACE_REGION}" +echo " Agent Space ID: ${AGENT_SPACE_ID}" +echo "" + +# --------------------------------------------------------------------------- +# CDK deploy the registration stack +# --------------------------------------------------------------------------- +echo "[2/3] Installing CDK dependencies..." +python3 -m pip install -r "${CDK_DIR}/requirements.txt" --quiet + +echo "[3/3] Deploying SaasStatusMcpRegistrationStack via CDK..." +echo " (creates IAM role, DevOps Agent Service, Association)" +echo "" + +STACK_ID="SaasStatusMcpRegistrationStack-${AGENT_SPACE_REGION}" +export PYTHONPATH="${REPO_ROOT}" + +pushd "${CDK_DIR}" > /dev/null +npx cdk deploy "${STACK_ID}" \ + --require-approval never \ + --context "agent_space_id=${AGENT_SPACE_ID}" \ + --context "agent_space_region=${AGENT_SPACE_REGION}" \ + --context "runtime_arn=${RUNTIME_ARN}" \ + --context "runtime_region=${RUNTIME_REGION}" +popd > /dev/null + +# --------------------------------------------------------------------------- +# Read outputs from the deployed registration stack +# --------------------------------------------------------------------------- +SERVICE_ID=$(aws cloudformation describe-stacks \ + --stack-name "${STACK_ID}" \ + --region "${AGENT_SPACE_REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='ServiceId'].OutputValue" \ + --output text 2>/dev/null || echo "N/A") + +SIGNING_ROLE=$(aws cloudformation describe-stacks \ + --stack-name "${STACK_ID}" \ + --region "${AGENT_SPACE_REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='SigningRoleArn'].OutputValue" \ + --output text 2>/dev/null || echo "N/A") + +echo "" +echo "==============================================" +echo " Registration Complete" +echo "==============================================" +echo "" +echo " CDK stack: ${STACK_ID}" +echo " Service ID: ${SERVICE_ID}" +echo " Agent Space: ${AGENT_SPACE_ID} (${AGENT_SPACE_REGION})" +echo " Signing role: ${SIGNING_ROLE}" +echo " MCP name: saas-status-mcp" +echo " Tools: 4 enabled" +echo "" +exit 0 diff --git a/mcp/saas-status-mcp/tests/__init__.py b/mcp/saas-status-mcp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/saas-status-mcp/tests/fixtures/incidents_unresolved.json b/mcp/saas-status-mcp/tests/fixtures/incidents_unresolved.json new file mode 100644 index 0000000..9f26aa5 --- /dev/null +++ b/mcp/saas-status-mcp/tests/fixtures/incidents_unresolved.json @@ -0,0 +1,52 @@ +{ + "page": { + "id": "abc123", + "name": "MongoDB Atlas Status", + "url": "https://status.cloud.mongodb.com" + }, + "incidents": [ + { + "id": "7g5qmxgkc2y4", + "name": "Impaired Cluster Operations – AWS me-central-1 and AWS me-south-1", + "status": "monitoring", + "impact": "major", + "created_at": "2026-03-01T13:48:14.360Z", + "updated_at": "2026-06-03T15:46:56.709Z", + "started_at": "2026-03-01T13:48:14.360Z", + "resolved_at": null, + "shortlink": "https://stspg.io/mg7m971rdhw8", + "components": [ + { + "id": "comp1", + "name": "Cloud Services - AWS me-central-1", + "status": "degraded_performance" + }, + { + "id": "comp2", + "name": "Cloud Services - AWS me-south-1", + "status": "degraded_performance" + } + ], + "incident_updates": [ + { + "id": "upd1", + "status": "monitoring", + "body": "We are continuing to monitor cluster operations in these regions.", + "created_at": "2026-06-03T15:46:56.709Z" + }, + { + "id": "upd2", + "status": "identified", + "body": "The issue has been identified and a fix is being implemented.", + "created_at": "2026-03-01T14:30:00.000Z" + }, + { + "id": "upd3", + "status": "investigating", + "body": "We are investigating reports of impaired cluster operations.", + "created_at": "2026-03-01T13:48:14.360Z" + } + ] + } + ] +} diff --git a/mcp/saas-status-mcp/tests/fixtures/maintenances_active.json b/mcp/saas-status-mcp/tests/fixtures/maintenances_active.json new file mode 100644 index 0000000..2ccd049 --- /dev/null +++ b/mcp/saas-status-mcp/tests/fixtures/maintenances_active.json @@ -0,0 +1,41 @@ +{ + "page": { + "id": "abc123", + "name": "Datadog Status", + "url": "https://status.datadoghq.com" + }, + "scheduled_maintenances": [ + { + "id": "maint001", + "name": "Scheduled database migration - US1 region", + "status": "in_progress", + "impact": "maintenance", + "created_at": "2026-07-01T10:00:00.000Z", + "updated_at": "2026-07-06T08:00:00.000Z", + "scheduled_for": "2026-07-06T06:00:00.000Z", + "scheduled_until": "2026-07-06T10:00:00.000Z", + "shortlink": "https://stspg.io/maint001", + "components": [ + { + "id": "comp_db", + "name": "Database Infrastructure - US1", + "status": "under_maintenance" + } + ], + "incident_updates": [ + { + "id": "mupd1", + "status": "in_progress", + "body": "Maintenance is currently in progress. Some queries may experience elevated latency.", + "created_at": "2026-07-06T06:05:00.000Z" + }, + { + "id": "mupd2", + "status": "scheduled", + "body": "This maintenance has been scheduled.", + "created_at": "2026-07-01T10:00:00.000Z" + } + ] + } + ] +} diff --git a/mcp/saas-status-mcp/tests/fixtures/status_degraded.json b/mcp/saas-status-mcp/tests/fixtures/status_degraded.json new file mode 100644 index 0000000..5f07a1c --- /dev/null +++ b/mcp/saas-status-mcp/tests/fixtures/status_degraded.json @@ -0,0 +1,12 @@ +{ + "page": { + "id": "abc123", + "name": "MongoDB Atlas Status", + "url": "https://status.cloud.mongodb.com", + "updated_at": "2026-07-06T14:00:00.000Z" + }, + "status": { + "indicator": "degraded_performance", + "description": "Degraded Performance" + } +} diff --git a/mcp/saas-status-mcp/tests/fixtures/status_operational.json b/mcp/saas-status-mcp/tests/fixtures/status_operational.json new file mode 100644 index 0000000..980409d --- /dev/null +++ b/mcp/saas-status-mcp/tests/fixtures/status_operational.json @@ -0,0 +1,12 @@ +{ + "page": { + "id": "abc123", + "name": "Snowflake Status", + "url": "https://status.snowflake.com", + "updated_at": "2026-07-06T15:30:00.000Z" + }, + "status": { + "indicator": "operational", + "description": "All Systems Operational" + } +} diff --git a/mcp/saas-status-mcp/tests/invoke_test.py b/mcp/saas-status-mcp/tests/invoke_test.py new file mode 100644 index 0000000..820b45c --- /dev/null +++ b/mcp/saas-status-mcp/tests/invoke_test.py @@ -0,0 +1,89 @@ +"""Test: invoke the deployed MCP server via bedrock-agentcore SDK. + +Based on the agentcore-samples invoke pattern: +https://github.com/awslabs/agentcore-samples/blob/main/01-features/02-host-your-agent/01-runtime/02-hosting-tools/01-mcp-server-basics/invoke.py +""" + +import json +import boto3 + +REGION = "eu-west-3" +# Replace with your deployed runtime ARN (see the RuntimeArn stack output). +RUNTIME_ARN = "arn:aws:bedrock-agentcore:eu-west-3:123456789012:runtime/saas_status_mcp-XXXXXXXXXX" + +client = boto3.client("bedrock-agentcore", region_name=REGION) + + +def send_mcp_rpc(method: str, params: dict, rpc_id: int = 1) -> dict: + """Send an MCP JSON-RPC message to the deployed server.""" + rpc_message = { + "jsonrpc": "2.0", + "method": method, + "id": rpc_id, + "params": params, + } + + response = client.invoke_agent_runtime( + agentRuntimeArn=RUNTIME_ARN, + payload=json.dumps(rpc_message).encode("utf-8"), + contentType="application/json", + accept="application/json, text/event-stream", + ) + + body = response["response"].read().decode("utf-8") + # streamable-http may return an SSE frame ("data: {...}") + if body.startswith("data:"): + body = body.split("data:", 1)[1].strip() + return json.loads(body) + + +def main(): + print(f"MCP Server: {RUNTIME_ARN}\n") + + # 1. Initialize + print("--- Initialize") + result = send_mcp_rpc( + "initialize", + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, + rpc_id=1, + ) + print(f" Server: {json.dumps(result.get('result', {}).get('serverInfo', {}))}\n") + + # 2. List tools + print("--- tools/list") + result = send_mcp_rpc("tools/list", {}, rpc_id=2) + tools = result.get("result", {}).get("tools", []) + for t in tools: + print(f" * {t['name']}: {t.get('description', '')[:70]}") + print() + + # 3. list_providers + print("--- tools/call: list_providers") + result = send_mcp_rpc("tools/call", {"name": "list_providers", "arguments": {}}, rpc_id=3) + print(f" {json.dumps(result.get('result', {}))[:600]}\n") + + # 4. get_service_status(mongodb) + print("--- tools/call: get_service_status(mongodb)") + result = send_mcp_rpc( + "tools/call", + {"name": "get_service_status", "arguments": {"provider": "mongodb"}}, + rpc_id=4, + ) + print(f" {json.dumps(result.get('result', {}))[:600]}\n") + + # 5. check_all_dependencies + print("--- tools/call: check_all_dependencies([snowflake, datadog, mongodb])") + result = send_mcp_rpc( + "tools/call", + {"name": "check_all_dependencies", "arguments": {"providers": ["snowflake", "datadog", "mongodb"]}}, + rpc_id=5, + ) + print(f" {json.dumps(result.get('result', {}))[:800]}\n") + + +if __name__ == "__main__": + main() diff --git a/mcp/saas-status-mcp/tests/test_tools.py b/mcp/saas-status-mcp/tests/test_tools.py new file mode 100644 index 0000000..85fba4d --- /dev/null +++ b/mcp/saas-status-mcp/tests/test_tools.py @@ -0,0 +1,187 @@ +"""Unit tests for the SaaS Status MCP tools (offline, httpx mocked via respx). + +The agent modules use flat imports (designed for the AgentCore zip root), so we +add the agent/ directory to sys.path and import `tools` directly. No network and +no AWS calls: PROVIDERS_BUCKET is unset, so config.py loads the local +providers.json seed. +""" + +import json +import sys +from pathlib import Path + +import pytest +import respx +from httpx import Response + +# Make the flat agent modules importable (config, statuspage_client, tools) +_AGENT_DIR = Path(__file__).resolve().parent.parent / "agent" +sys.path.insert(0, str(_AGENT_DIR)) + +import tools # noqa: E402 + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _fixture(name: str) -> dict: + with open(FIXTURES / name, "r") as f: + return json.load(f) + + +# ─── list_providers ─────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_list_providers_returns_registry(): + result = await tools.list_providers() + names = [p["name"] for p in result["providers"]] + assert "snowflake" in names + assert "datadog" in names + assert "mongodb" in names + assert len(names) >= 20 # shipped registry is broad + + +# ─── get_service_status ─────────────────────────────────────────── + +@pytest.mark.asyncio +@respx.mock +async def test_get_service_status_operational(): + respx.get("https://status.snowflake.com/api/v2/status.json").mock( + return_value=Response(200, json=_fixture("status_operational.json")) + ) + result = await tools.get_service_status("snowflake") + assert result["provider"] == "snowflake" + assert result["status"] == "operational" + assert result["url"] == "https://status.snowflake.com" + + +@pytest.mark.asyncio +@respx.mock +async def test_get_service_status_degraded(): + respx.get("https://status.mongodb.com/api/v2/status.json").mock( + return_value=Response(200, json=_fixture("status_degraded.json")) + ) + result = await tools.get_service_status("mongodb") + assert result["status"] == "degraded_performance" + + +@pytest.mark.asyncio +async def test_get_service_status_unknown_provider(): + result = await tools.get_service_status("nonexistent") + assert "error" in result + assert "Unknown provider" in result["error"] + + +# ─── get_active_events ──────────────────────────────────────────── + +@pytest.mark.asyncio +@respx.mock +async def test_get_active_events_with_incident(): + respx.get("https://status.mongodb.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json=_fixture("incidents_unresolved.json")) + ) + respx.get("https://status.mongodb.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json={"scheduled_maintenances": []}) + ) + + result = await tools.get_active_events("mongodb") + assert result["total_active"] == 1 + event = result["events"][0] + assert event["event_type"] == "incident" + assert event["id"] == "7g5qmxgkc2y4" + assert event["impact"] == "major" + assert len(event["affected_components"]) == 2 + assert event["latest_update"]["status"] == "monitoring" + assert event["updates"] is None # include_history=False by default + + +@pytest.mark.asyncio +@respx.mock +async def test_get_active_events_with_maintenance(): + respx.get("https://status.datadoghq.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json={"incidents": []}) + ) + respx.get("https://status.datadoghq.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json=_fixture("maintenances_active.json")) + ) + + result = await tools.get_active_events("datadog") + assert result["total_active"] == 1 + event = result["events"][0] + assert event["event_type"] == "maintenance" + assert event["scheduled_for"] == "2026-07-06T06:00:00.000Z" + assert event["scheduled_until"] == "2026-07-06T10:00:00.000Z" + + +@pytest.mark.asyncio +@respx.mock +async def test_get_active_events_empty(): + respx.get("https://status.snowflake.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json={"incidents": []}) + ) + respx.get("https://status.snowflake.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json={"scheduled_maintenances": []}) + ) + + result = await tools.get_active_events("snowflake") + assert result["total_active"] == 0 + assert result["events"] == [] + + +@pytest.mark.asyncio +@respx.mock +async def test_get_active_events_with_history(): + respx.get("https://status.mongodb.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json=_fixture("incidents_unresolved.json")) + ) + respx.get("https://status.mongodb.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json={"scheduled_maintenances": []}) + ) + + result = await tools.get_active_events("mongodb", include_history=True) + event = result["events"][0] + assert event["updates"] is not None + assert len(event["updates"]) == 3 + + +# ─── check_all_dependencies ────────────────────────────────────── + +@pytest.mark.asyncio +@respx.mock +async def test_check_all_dependencies_mixed(): + # Snowflake: operational, no events + respx.get("https://status.snowflake.com/api/v2/status.json").mock( + return_value=Response(200, json=_fixture("status_operational.json")) + ) + respx.get("https://status.snowflake.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json={"incidents": []}) + ) + respx.get("https://status.snowflake.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json={"scheduled_maintenances": []}) + ) + + # MongoDB: degraded, 1 incident + respx.get("https://status.mongodb.com/api/v2/status.json").mock( + return_value=Response(200, json=_fixture("status_degraded.json")) + ) + respx.get("https://status.mongodb.com/api/v2/incidents/unresolved.json").mock( + return_value=Response(200, json=_fixture("incidents_unresolved.json")) + ) + respx.get("https://status.mongodb.com/api/v2/scheduled-maintenances/active.json").mock( + return_value=Response(200, json={"scheduled_maintenances": []}) + ) + + result = await tools.check_all_dependencies(["snowflake", "mongodb"]) + assert result["any_degraded"] is True + assert result["degraded_providers"] == ["mongodb"] + assert len(result["results"]) == 2 + assert result["results"][0]["status"] == "operational" + assert result["results"][0]["active_events"] == 0 + assert result["results"][1]["status"] == "degraded_performance" + assert result["results"][1]["active_events"] == 1 + + +@pytest.mark.asyncio +async def test_check_all_dependencies_too_many(): + result = await tools.check_all_dependencies(["a"] * 11) + assert "error" in result + assert "Maximum 10" in result["error"]