diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b661519..ab7d184 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,8 +24,24 @@ jobs: with: version: v1.64 - test: - name: Test + test-unit: + name: Test Unit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Run unit tests + run: make test-unit + + test-integration: + name: Test Integration runs-on: ubuntu-latest steps: - name: Checkout code @@ -48,5 +64,25 @@ jobs: - name: Wait for Grafana server and Prometheus server to start and scrape run: sleep 30 - - name: Run tests - run: make test-all + - name: Run integration tests + run: make test-integration + + test-cloud: + name: Test Cloud + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Run cloud tests + env: + GRAFANA_URL: ${{ vars.CLOUD_GRAFANA_URL }} + GRAFANA_API_KEY: ${{ secrets.CLOUD_GRAFANA_API_KEY }} + run: make test-cloud + diff --git a/Makefile b/Makefile index f3324f8..cd3d209 100644 --- a/Makefile +++ b/Makefile @@ -16,14 +16,19 @@ build-image: ## Build the Docker image. lint: ## Lint the Go code. go tool -modfile go.tools.mod golangci-lint run -.PHONY: test -test: ## Run the Go unit tests. - go test ./... +.PHONY: test test-unit +test-unit: ## Run the unit tests (no external dependencies required). + go test -v -tags unit ./... +test: test-unit -.PHONY: test-all -test-all: ## Run the Go unit and integration tests. +.PHONY: test-integration +test-integration: ## Run only the Docker-based integration tests (Requires docker containers to be up and running). go test -v -tags integration ./... +.PHONY: test-cloud +test-cloud: ## Run only the cloud-based tests (requires cloud Grafana instance and credentials). + go test -v -tags cloud ./tools + .PHONY: run run: ## Run the MCP server in stdio mode. go run ./... diff --git a/README.md b/README.md index 6a19980..bce69ae 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ This provides access to your Grafana instance and the surrounding ecosystem. - [ ] Create and change alert rules - [ ] List contact points - [ ] Create and change contact points +- [x] Access Grafana OnCall functionality + - [x] List and manage schedules + - [x] Get shift details + - [x] Get current on-call users + - [x] List teams and users + - [ ] List alert groups The list of tools is configurable, so you can choose which tools you want to make available to the MCP client. This is useful if you don't use certain functionality or if you don't want to take up too much of the context window. @@ -61,6 +67,11 @@ This is useful if you don't use certain functionality or if you don't want to ta | `query_loki_stats` | Loki | Get statistics about log streams | | `list_alert_rules` | Alerting | List alert rules | | `get_alert_rule_by_uid` | Alerting | Get alert rule by UID | +| `list_oncall_schedules` | OnCall | List schedules from Grafana OnCall | +| `get_oncall_shift` | OnCall | Get details for a specific OnCall shift | +| `get_current_oncall_users` | OnCall | Get users currently on-call for a specific schedule | +| `list_oncall_teams` | OnCall | List teams from Grafana OnCall | +| `list_oncall_users` | OnCall | List users from Grafana OnCall | ## Usage @@ -122,13 +133,28 @@ docker run -it --rm -p 8000:8000 mcp-grafana:latest ### Testing -To run unit tests, run: +There are three types of tests available: +1. Unit Tests (no external dependencies required): +```bash +make test-unit +``` + +You can also run unit tests with: ```bash make test ``` -**TODO: add integration tests and cloud tests.** +2. Integration Tests (requires docker containers to be up and running): +```bash +make test-integration +``` + +3. Cloud Tests (requires cloud Grafana instance and credentials): +```bash +make test-cloud +``` +> Note: Cloud tests are automatically configured in CI. For local development, you'll need to set up your own Grafana Cloud instance and credentials. More comprehensive integration tests will require a Grafana instance to be running locally on port 3000; you can start one with Docker Compose: diff --git a/cmd/mcp-grafana/main.go b/cmd/mcp-grafana/main.go index b4d2167..73b0acd 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -25,6 +25,7 @@ func newServer() *server.MCPServer { tools.AddLokiTools(s) tools.AddAlertingTools(s) tools.AddDashboardTools(s) + tools.AddOnCallTools(s) return s } diff --git a/go.mod b/go.mod index f20895f..754c6e6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/go-openapi/strfmt v0.23.0 + github.com/grafana/amixr-api-go-client v0.0.20 github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65 github.com/grafana/incident-go v0.0.0-20250211094540-dc6a98fdae43 github.com/invopop/jsonschema v0.13.0 @@ -32,8 +33,11 @@ require ( github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index efc93e0..214c7ea 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,9 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -37,17 +40,29 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/amixr-api-go-client v0.0.20 h1:/L44PCP0H8y0Z6NSZmJFCUH3Nc5gRPphKrp2Ck7ufz0= +github.com/grafana/amixr-api-go-client v0.0.20/go.mod h1:u53FF0WSBMx6XvZK58fply91KBl6X+OtIu0aJC07amY= github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65 h1:AnfwjPE8TXJO8CX0Q5PvtzGta9Ls3iRASWVV4jHl4KA= github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= github.com/grafana/incident-go v0.0.0-20250211094540-dc6a98fdae43 h1:+MCsOKi5BJ1wO3PRj3eDNxCScYwE2IcKNW1t8OcTarE= github.com/grafana/incident-go v0.0.0-20250211094540-dc6a98fdae43/go.mod h1:3QDfdZOWKRxNhMJFL+0C/+12+jLNHDlt0VKNr/i9Daw= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -68,6 +83,15 @@ github.com/mark3labs/mcp-go v0.15.0 h1:lViiC4dk6chJHZccezaTzZLMOQVUXJDGNQPtzExr5 github.com/mark3labs/mcp-go v0.15.0/go.mod h1:xBB350hekQsJAK7gJAii8bcEoWemboLm2mRm5/+KBaU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -100,6 +124,7 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -124,10 +149,21 @@ golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/mcpgrafana_test.go b/mcpgrafana_test.go index cec3e73..9146cff 100644 --- a/mcpgrafana_test.go +++ b/mcpgrafana_test.go @@ -1,3 +1,6 @@ +//go:build unit +// +build unit + package mcpgrafana import ( diff --git a/tools/incident_integration_test.go b/tools/incident_integration_test.go index f2ca8cc..b65dda0 100644 --- a/tools/incident_integration_test.go +++ b/tools/incident_integration_test.go @@ -1,12 +1,13 @@ // Requires a Cloud or other Grafana instance with Grafana Incident available, // with a Prometheus datasource provisioned. -// Run with `go test -tags integration,cloud`. -//go:build integration && cloud +//go:build cloud +// +build cloud package tools import ( "context" + "os" "testing" mcpgrafana "github.com/grafana/mcp-grafana" @@ -14,13 +15,39 @@ import ( "github.com/stretchr/testify/require" ) +// This file contains cloud integration tests that run against a dedicated test instance +// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Incident side +// with two incidents created, one minor and one major, and both of them resolved. +// These tests expect this configuration to exist and will skip if the required +// environment variables (GRAFANA_URL, GRAFANA_API_KEY) are not set. + +func createCloudTestContext(t *testing.T) context.Context { + grafanaURL := os.Getenv("GRAFANA_URL") + if grafanaURL == "" { + t.Skip("GRAFANA_URL environment variable not set, skipping cloud Incident integration tests") + } + + grafanaApiKey := os.Getenv("GRAFANA_API_KEY") + if grafanaApiKey == "" { + t.Skip("GRAFANA_API_KEY environment variable not set, skipping cloud Incident integration tests") + } + + ctx := context.Background() + ctx = mcpgrafana.WithGrafanaURL(ctx, grafanaURL) + ctx = mcpgrafana.WithGrafanaAPIKey(ctx, grafanaApiKey) + ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx) + return ctx +} + func TestCloudIncidentTools(t *testing.T) { t.Run("list incidents", func(t *testing.T) { - ctx := mcpgrafana.ExtractIncidentClientFromEnv(context.Background()) + ctx := createCloudTestContext(t) result, err := listIncidents(ctx, ListIncidentsParams{ - Limit: 2, + Limit: 1, }) require.NoError(t, err) - assert.Len(t, result.IncidentPreviews, 2) + assert.NotNil(t, result, "Result should not be nil") + assert.NotNil(t, result.IncidentPreviews, "IncidentPreviews should not be nil") + assert.LessOrEqual(t, len(result.IncidentPreviews), 1, "Should not return more incidents than the limit") }) } diff --git a/tools/incident_test.go b/tools/incident_test.go index 46e99b8..65582f2 100644 --- a/tools/incident_test.go +++ b/tools/incident_test.go @@ -1,3 +1,6 @@ +//go:build unit +// +build unit + package tools import ( diff --git a/tools/oncall.go b/tools/oncall.go new file mode 100644 index 0000000..f8e9c48 --- /dev/null +++ b/tools/oncall.go @@ -0,0 +1,299 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + aapi "github.com/grafana/amixr-api-go-client" + mcpgrafana "github.com/grafana/mcp-grafana" + "github.com/mark3labs/mcp-go/server" +) + +// getOnCallURLFromSettings retrieves the OnCall API URL from the Grafana settings endpoint. +// It makes a GET request to /api/plugins/grafana-irm-app/settings and extracts +// the OnCall URL from the jsonData.onCallApiUrl field in the response. +// Returns the OnCall URL if found, or an error if the URL cannot be retrieved. +func getOnCallURLFromSettings(ctx context.Context, grafanaURL, grafanaAPIKey string) (string, error) { + settingsURL := fmt.Sprintf("%s/api/plugins/grafana-irm-app/settings", strings.TrimRight(grafanaURL, "/")) + + req, err := http.NewRequestWithContext(ctx, "GET", settingsURL, nil) + if err != nil { + return "", fmt.Errorf("creating settings request: %w", err) + } + + if grafanaAPIKey != "" { + req.Header.Set("Authorization", "Bearer "+grafanaAPIKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching settings: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code from settings API: %d", resp.StatusCode) + } + + var settings struct { + JSONData struct { + OnCallAPIURL string `json:"onCallApiUrl"` + } `json:"jsonData"` + } + + if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil { + return "", fmt.Errorf("decoding settings response: %w", err) + } + + if settings.JSONData.OnCallAPIURL == "" { + return "", fmt.Errorf("OnCall API URL is not set in settings") + } + + return settings.JSONData.OnCallAPIURL, nil +} + +func oncallClientFromContext(ctx context.Context) (*aapi.Client, error) { + // Get the standard Grafana URL and API key + grafanaURL, grafanaAPIKey := mcpgrafana.GrafanaURLFromContext(ctx), mcpgrafana.GrafanaAPIKeyFromContext(ctx) + + // Try to get OnCall URL from settings endpoint + grafanaOnCallURL, err := getOnCallURLFromSettings(ctx, grafanaURL, grafanaAPIKey) + if err != nil { + return nil, fmt.Errorf("getting OnCall URL from settings: %w", err) + } + + grafanaOnCallURL = strings.TrimRight(grafanaOnCallURL, "/") + + client, err := aapi.NewWithGrafanaURL(grafanaOnCallURL, grafanaAPIKey, grafanaURL) + if err != nil { + return nil, fmt.Errorf("creating OnCall client: %w", err) + } + + return client, nil +} + +type ListOnCallSchedulesParams struct { + TeamID string `json:"teamId,omitempty" jsonschema:"description=The ID of the team to list schedules for"` + ScheduleID string `json:"scheduleId,omitempty" jsonschema:"description=The ID of the schedule to get details for. If provided, returns only that schedule's details"` + Page int `json:"page,omitempty" jsonschema:"description=The page number to return (1-based)"` +} + +// ScheduleSummary represents a simplified view of an OnCall schedule +type ScheduleSummary struct { + ID string `json:"id" jsonschema:"description=The unique identifier of the schedule"` + Name string `json:"name" jsonschema:"description=The name of the schedule"` + TeamID string `json:"teamId" jsonschema:"description=The ID of the team this schedule belongs to"` + Timezone string `json:"timezone" jsonschema:"description=The timezone for this schedule"` + Shifts []string `json:"shifts" jsonschema:"description=List of shift IDs in this schedule"` +} + +func listOnCallSchedules(ctx context.Context, args ListOnCallSchedulesParams) ([]*ScheduleSummary, error) { + client, err := oncallClientFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting OnCall client: %w", err) + } + + scheduleService := aapi.NewScheduleService(client) + + if args.ScheduleID != "" { + schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{}) + if err != nil { + return nil, fmt.Errorf("getting OnCall schedule %s: %w", args.ScheduleID, err) + } + summary := &ScheduleSummary{ + ID: schedule.ID, + Name: schedule.Name, + TeamID: schedule.TeamId, + Timezone: schedule.TimeZone, + } + if schedule.Shifts != nil { + summary.Shifts = *schedule.Shifts + } + return []*ScheduleSummary{summary}, nil + } + + listOptions := &aapi.ListScheduleOptions{} + if args.Page > 0 { + listOptions.Page = args.Page + } + + response, _, err := scheduleService.ListSchedules(listOptions) + if err != nil { + return nil, fmt.Errorf("listing OnCall schedules: %w", err) + } + + // Convert schedules to summaries + summaries := make([]*ScheduleSummary, 0, len(response.Schedules)) + for _, schedule := range response.Schedules { + // Filter by team ID if provided. Note: We filter here because the API doesn't support + // filtering by team ID directly in the ListSchedules endpoint. + if args.TeamID != "" && schedule.TeamId != args.TeamID { + continue + } + summary := &ScheduleSummary{ + ID: schedule.ID, + Name: schedule.Name, + TeamID: schedule.TeamId, + Timezone: schedule.TimeZone, + } + if schedule.Shifts != nil { + summary.Shifts = *schedule.Shifts + } + summaries = append(summaries, summary) + } + + return summaries, nil +} + +var ListOnCallSchedules = mcpgrafana.MustTool( + "list_oncall_schedules", + "List OnCall schedules. A schedule is a calendar-based system defining when team members are on-call. Optionally provide a scheduleId to get details for a specific schedule", + listOnCallSchedules, +) + +type GetOnCallShiftParams struct { + ShiftID string `json:"shiftId" jsonschema:"required,description=The ID of the shift to get details for"` +} + +func getOnCallShift(ctx context.Context, args GetOnCallShiftParams) (*aapi.OnCallShift, error) { + client, err := oncallClientFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting OnCall client: %w", err) + } + + shiftService := aapi.NewOnCallShiftService(client) + shift, _, err := shiftService.GetOnCallShift(args.ShiftID, &aapi.GetOnCallShiftOptions{}) + if err != nil { + return nil, fmt.Errorf("getting OnCall shift %s: %w", args.ShiftID, err) + } + + return shift, nil +} + +var GetOnCallShift = mcpgrafana.MustTool( + "get_oncall_shift", + "Get details for a specific OnCall shift. A shift represents a designated time period within a rotation when a team or individual is actively on-call", + getOnCallShift, +) + +// CurrentOnCallUsers represents the currently on-call users for a schedule +type CurrentOnCallUsers struct { + ScheduleID string `json:"scheduleId" jsonschema:"description=The ID of the schedule"` + ScheduleName string `json:"scheduleName" jsonschema:"description=The name of the schedule"` + Users []string `json:"users" jsonschema:"description=List of user IDs currently on call"` +} + +type GetCurrentOnCallUsersParams struct { + ScheduleID string `json:"scheduleId" jsonschema:"required,description=The ID of the schedule to get current on-call users for"` +} + +func getCurrentOnCallUsers(ctx context.Context, args GetCurrentOnCallUsersParams) (*CurrentOnCallUsers, error) { + client, err := oncallClientFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting OnCall client: %w", err) + } + + scheduleService := aapi.NewScheduleService(client) + schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{}) + if err != nil { + return nil, fmt.Errorf("getting schedule %s: %w", args.ScheduleID, err) + } + + return &CurrentOnCallUsers{ + ScheduleID: schedule.ID, + ScheduleName: schedule.Name, + Users: schedule.OnCallNow, + }, nil +} + +var GetCurrentOnCallUsers = mcpgrafana.MustTool( + "get_current_oncall_users", + "Get users currently on-call for a specific schedule. A schedule is a calendar-based system defining when team members are on-call", + getCurrentOnCallUsers, +) + +type ListOnCallTeamsParams struct { + Page int `json:"page,omitempty" jsonschema:"description=The page number to return"` +} + +func listOnCallTeams(ctx context.Context, args ListOnCallTeamsParams) ([]*aapi.Team, error) { + client, err := oncallClientFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting OnCall client: %w", err) + } + + listOptions := &aapi.ListTeamOptions{} + if args.Page > 0 { + listOptions.Page = args.Page + } + + teamService := aapi.NewTeamService(client) + response, _, err := teamService.ListTeams(listOptions) + if err != nil { + return nil, fmt.Errorf("listing OnCall teams: %w", err) + } + + return response.Teams, nil +} + +var ListOnCallTeams = mcpgrafana.MustTool( + "list_oncall_teams", + "List teams from Grafana OnCall", + listOnCallTeams, +) + +type ListOnCallUsersParams struct { + UserID string `json:"userId,omitempty" jsonschema:"description=The ID of the user to get details for. If provided, returns only that user's details"` + Username string `json:"username,omitempty" jsonschema:"description=The username to filter users by. If provided, returns only the user matching this username"` + Page int `json:"page,omitempty" jsonschema:"description=The page number to return"` +} + +func listOnCallUsers(ctx context.Context, args ListOnCallUsersParams) ([]*aapi.User, error) { + client, err := oncallClientFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting OnCall client: %w", err) + } + + userService := aapi.NewUserService(client) + + if args.UserID != "" { + user, _, err := userService.GetUser(args.UserID, &aapi.GetUserOptions{}) + if err != nil { + return nil, fmt.Errorf("getting OnCall user %s: %w", args.UserID, err) + } + return []*aapi.User{user}, nil + } + + // Otherwise, list all users + listOptions := &aapi.ListUserOptions{} + if args.Page > 0 { + listOptions.Page = args.Page + } + if args.Username != "" { + listOptions.Username = args.Username + } + + response, _, err := userService.ListUsers(listOptions) + if err != nil { + return nil, fmt.Errorf("listing OnCall users: %w", err) + } + + return response.Users, nil +} + +var ListOnCallUsers = mcpgrafana.MustTool( + "list_oncall_users", + "List users from Grafana OnCall. If user ID is provided, returns details for that specific user. If username is provided, returns the user matching that username", + listOnCallUsers, +) + +func AddOnCallTools(mcp *server.MCPServer) { + ListOnCallSchedules.Register(mcp) + GetOnCallShift.Register(mcp) + GetCurrentOnCallUsers.Register(mcp) + ListOnCallTeams.Register(mcp) + ListOnCallUsers.Register(mcp) +} diff --git a/tools/oncall_cloud_test.go b/tools/oncall_cloud_test.go new file mode 100644 index 0000000..3e8c380 --- /dev/null +++ b/tools/oncall_cloud_test.go @@ -0,0 +1,267 @@ +//go:build cloud +// +build cloud + +// This file contains cloud integration tests that run against a dedicated test instance +// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the OnCall side: +// - One team +// - Two schedules (only one has a team assigned) +// - One shift in the schedule with a team assigned +// - One user +// These tests expect this configuration to exist and will skip if the required +// environment variables (GRAFANA_URL, GRAFANA_API_KEY) are not set. + +package tools + +import ( + "context" + "os" + "testing" + + mcpgrafana "github.com/grafana/mcp-grafana" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createOnCallCloudTestContext(t *testing.T) context.Context { + grafanaURL := os.Getenv("GRAFANA_URL") + if grafanaURL == "" { + t.Skip("GRAFANA_URL environment variable not set, skipping cloud OnCall integration tests") + } + + grafanaApiKey := os.Getenv("GRAFANA_API_KEY") + if grafanaApiKey == "" { + t.Skip("GRAFANA_API_KEY environment variable not set, skipping cloud OnCall integration tests") + } + + ctx := context.Background() + ctx = mcpgrafana.WithGrafanaURL(ctx, grafanaURL) + ctx = mcpgrafana.WithGrafanaAPIKey(ctx, grafanaApiKey) + + return ctx +} + +func TestCloudOnCallSchedules(t *testing.T) { + ctx := createOnCallCloudTestContext(t) + + // Test listing all schedules + t.Run("list all schedules", func(t *testing.T) { + result, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{}) + require.NoError(t, err, "Should not error when listing schedules") + assert.NotNil(t, result, "Result should not be nil") + }) + + // Test pagination + t.Run("list schedules with pagination", func(t *testing.T) { + // Get first page + page1, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{Page: 1}) + require.NoError(t, err, "Should not error when listing schedules page 1") + assert.NotNil(t, page1, "Page 1 should not be nil") + + // Get second page + page2, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{Page: 2}) + require.NoError(t, err, "Should not error when listing schedules page 2") + assert.NotNil(t, page2, "Page 2 should not be nil") + }) + + // Get a team ID from an existing schedule to test filtering + schedules, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{}) + require.NoError(t, err, "Should not error when listing schedules") + + if len(schedules) > 0 && schedules[0].TeamID != "" { + teamID := schedules[0].TeamID + + // Test filtering by team ID + t.Run("list schedules by team ID", func(t *testing.T) { + result, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{ + TeamID: teamID, + }) + require.NoError(t, err, "Should not error when listing schedules by team") + assert.NotEmpty(t, result, "Should return at least one schedule") + for _, schedule := range result { + assert.Equal(t, teamID, schedule.TeamID, "All schedules should belong to the specified team") + } + }) + } + + // Test getting a specific schedule + if len(schedules) > 0 { + scheduleID := schedules[0].ID + t.Run("get specific schedule", func(t *testing.T) { + result, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{ + ScheduleID: scheduleID, + }) + require.NoError(t, err, "Should not error when getting specific schedule") + assert.Len(t, result, 1, "Should return exactly one schedule") + assert.Equal(t, scheduleID, result[0].ID, "Should return the correct schedule") + + // Verify all summary fields are present + schedule := result[0] + assert.NotEmpty(t, schedule.Name, "Schedule should have a name") + assert.NotEmpty(t, schedule.Timezone, "Schedule should have a timezone") + assert.NotNil(t, schedule.Shifts, "Schedule should have a shifts field") + }) + } +} + +func TestCloudOnCallShift(t *testing.T) { + ctx := createOnCallCloudTestContext(t) + + // First get a schedule to find a valid shift + schedules, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{}) + require.NoError(t, err, "Should not error when listing schedules") + require.NotEmpty(t, schedules, "Should have at least one schedule to test with") + require.NotEmpty(t, schedules[0].Shifts, "Schedule should have at least one shift") + + shifts := schedules[0].Shifts + shiftID := shifts[0] + + // Test getting shift details with valid ID + t.Run("get shift details", func(t *testing.T) { + result, err := getOnCallShift(ctx, GetOnCallShiftParams{ + ShiftID: shiftID, + }) + require.NoError(t, err, "Should not error when getting shift details") + assert.NotNil(t, result, "Result should not be nil") + assert.Equal(t, shiftID, result.ID, "Should return the correct shift") + }) + + t.Run("get shift with invalid ID", func(t *testing.T) { + _, err := getOnCallShift(ctx, GetOnCallShiftParams{ + ShiftID: "invalid-shift-id", + }) + assert.Error(t, err, "Should error when getting shift with invalid ID") + }) +} + +func TestCloudGetCurrentOnCallUsers(t *testing.T) { + ctx := createOnCallCloudTestContext(t) + + // First get a schedule to use for testing + schedules, err := listOnCallSchedules(ctx, ListOnCallSchedulesParams{}) + require.NoError(t, err, "Should not error when listing schedules") + require.NotEmpty(t, schedules, "Should have at least one schedule to test with") + + scheduleID := schedules[0].ID + + // Test getting current on-call users + t.Run("get current on-call users", func(t *testing.T) { + result, err := getCurrentOnCallUsers(ctx, GetCurrentOnCallUsersParams{ + ScheduleID: scheduleID, + }) + require.NoError(t, err, "Should not error when getting current on-call users") + assert.NotNil(t, result, "Result should not be nil") + assert.Equal(t, scheduleID, result.ScheduleID, "Should return the correct schedule") + assert.NotEmpty(t, result.ScheduleName, "Schedule should have a name") + assert.NotNil(t, result.Users, "Users field should be present") + }) + + t.Run("get current on-call users with invalid schedule ID", func(t *testing.T) { + _, err := getCurrentOnCallUsers(ctx, GetCurrentOnCallUsersParams{ + ScheduleID: "invalid-schedule-id", + }) + assert.Error(t, err, "Should error when getting current on-call users with invalid schedule ID") + }) +} + +func TestCloudOnCallTeams(t *testing.T) { + ctx := createOnCallCloudTestContext(t) + + t.Run("list teams", func(t *testing.T) { + result, err := listOnCallTeams(ctx, ListOnCallTeamsParams{}) + require.NoError(t, err, "Should not error when listing teams") + assert.NotNil(t, result, "Result should not be nil") + + if len(result) > 0 { + team := result[0] + assert.NotEmpty(t, team.ID, "Team should have an ID") + assert.NotEmpty(t, team.Name, "Team should have a name") + } + }) + + // Test pagination + t.Run("list teams with pagination", func(t *testing.T) { + // Get first page + page1, err := listOnCallTeams(ctx, ListOnCallTeamsParams{Page: 1}) + require.NoError(t, err, "Should not error when listing teams page 1") + assert.NotNil(t, page1, "Page 1 should not be nil") + + // Get second page + page2, err := listOnCallTeams(ctx, ListOnCallTeamsParams{Page: 2}) + require.NoError(t, err, "Should not error when listing teams page 2") + assert.NotNil(t, page2, "Page 2 should not be nil") + }) +} + +func TestCloudOnCallUsers(t *testing.T) { + ctx := createOnCallCloudTestContext(t) + + t.Run("list all users", func(t *testing.T) { + result, err := listOnCallUsers(ctx, ListOnCallUsersParams{}) + require.NoError(t, err, "Should not error when listing users") + assert.NotNil(t, result, "Result should not be nil") + + if len(result) > 0 { + user := result[0] + assert.NotEmpty(t, user.ID, "User should have an ID") + assert.NotEmpty(t, user.Username, "User should have a username") + } + }) + + // Test pagination + t.Run("list users with pagination", func(t *testing.T) { + // Get first page + page1, err := listOnCallUsers(ctx, ListOnCallUsersParams{Page: 1}) + require.NoError(t, err, "Should not error when listing users page 1") + assert.NotNil(t, page1, "Page 1 should not be nil") + + // Get second page + page2, err := listOnCallUsers(ctx, ListOnCallUsersParams{Page: 2}) + require.NoError(t, err, "Should not error when listing users page 2") + assert.NotNil(t, page2, "Page 2 should not be nil") + }) + + // Get a user ID and username from the list to test filtering + users, err := listOnCallUsers(ctx, ListOnCallUsersParams{}) + require.NoError(t, err, "Should not error when listing users") + require.NotEmpty(t, users, "Should have at least one user to test with") + + userID := users[0].ID + username := users[0].Username + + t.Run("get user by ID", func(t *testing.T) { + result, err := listOnCallUsers(ctx, ListOnCallUsersParams{ + UserID: userID, + }) + require.NoError(t, err, "Should not error when getting user by ID") + assert.NotNil(t, result, "Result should not be nil") + assert.Len(t, result, 1, "Should return exactly one user") + assert.Equal(t, userID, result[0].ID, "Should return the correct user") + assert.NotEmpty(t, result[0].Username, "User should have a username") + }) + + t.Run("get user by username", func(t *testing.T) { + result, err := listOnCallUsers(ctx, ListOnCallUsersParams{ + Username: username, + }) + require.NoError(t, err, "Should not error when getting user by username") + assert.NotNil(t, result, "Result should not be nil") + assert.Len(t, result, 1, "Should return exactly one user") + assert.Equal(t, username, result[0].Username, "Should return the correct user") + assert.NotEmpty(t, result[0].ID, "User should have an ID") + }) + + t.Run("get user with invalid ID", func(t *testing.T) { + _, err := listOnCallUsers(ctx, ListOnCallUsersParams{ + UserID: "invalid-user-id", + }) + assert.Error(t, err, "Should error when getting user with invalid ID") + }) + + t.Run("get user with invalid username", func(t *testing.T) { + result, err := listOnCallUsers(ctx, ListOnCallUsersParams{ + Username: "invalid-username", + }) + require.NoError(t, err, "Should not error when getting user with invalid username") + assert.Empty(t, result, "Should return empty result set for invalid username") + }) +} diff --git a/tools_test.go b/tools_test.go index 7c69a07..a0b45da 100644 --- a/tools_test.go +++ b/tools_test.go @@ -1,3 +1,6 @@ +//go:build unit +// +build unit + package mcpgrafana import (