diff --git a/README.md b/README.md index 1cf9d1a..9966df7 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The `resources` field accepts the following resource names (case-insensitive, co | `variable` | Project-level variable tools | | `globalvariable` | Global variable tools | | `plugin` | Plugin tools | +| `migrate` | Grafana → Perses dashboard migration tool | #### Environment Variables diff --git a/go.mod b/go.mod index 0706ecb..9a3a02c 100644 --- a/go.mod +++ b/go.mod @@ -12,13 +12,17 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PaesslerAG/gval v1.2.4 // indirect + github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect @@ -46,6 +50,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/cel-go v0.29.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/goreleaser/chglog v0.7.3 // indirect github.com/goreleaser/fileglob v1.4.0 // indirect @@ -103,7 +108,9 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect @@ -111,6 +118,8 @@ require ( golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index ae0e86d..33f35f4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -11,10 +13,18 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= +github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f h1:TxDCeKRCgHea2hUiMOjWwqzWmrIGqSOZYkEPuClXzDo= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -96,8 +106,11 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -219,6 +232,7 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -313,6 +327,10 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/permcp/server.go b/internal/permcp/server.go index 79b63af..cddc4d5 100644 --- a/internal/permcp/server.go +++ b/internal/permcp/server.go @@ -22,7 +22,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/perses/common/async" "github.com/perses/common/set" - v1 "github.com/perses/perses/pkg/client/api/v1" + apiclient "github.com/perses/perses/pkg/client/api" "github.com/perses/perses/pkg/client/config" "github.com/sirupsen/logrus" @@ -34,6 +34,7 @@ import ( "github.com/perses/mcp-server/pkg/tools/globalrole" "github.com/perses/mcp-server/pkg/tools/globalrolebinding" "github.com/perses/mcp-server/pkg/tools/globalvariable" + "github.com/perses/mcp-server/pkg/tools/migrate" "github.com/perses/mcp-server/pkg/tools/plugin" "github.com/perses/mcp-server/pkg/tools/project" "github.com/perses/mcp-server/pkg/tools/resource" @@ -66,13 +67,13 @@ func New(cfg Config) (async.SimpleTask, error) { }, nil } -func initializePersesClient(cfg Config) (v1.ClientInterface, error) { +func initializePersesClient(cfg Config) (apiclient.ClientInterface, error) { restClient, err := config.NewRESTClient(cfg.PersesServer) if err != nil { return nil, fmt.Errorf("error creating Perses REST client: %w", err) } - return v1.NewWithClient(restClient), nil + return apiclient.NewWithClient(restClient), nil } type server struct { @@ -80,7 +81,7 @@ type server struct { // cfg contains the server configuration settings cfg Config // persesClient is the client interface for interacting with the Perses API - persesClient v1.ClientInterface + persesClient apiclient.ClientInterface // mcpServer is the Model Context Protocol server instance mcpServer *mcp.Server } @@ -127,19 +128,21 @@ func (s *server) Execute(ctx context.Context, cancelFunc context.CancelFunc) err } func (s *server) registerTools() { + v1Client := s.persesClient.V1() resources := []resource.Resource{ - project.New(s.persesClient), - dashboard.New(s.persesClient), - ephemeraldashboard.New(s.persesClient), - datasource.New(s.persesClient), - globaldatasource.New(s.persesClient), - role.New(s.persesClient), - globalrole.New(s.persesClient), - rolebinding.New(s.persesClient), - globalrolebinding.New(s.persesClient), - variable.New(s.persesClient), - globalvariable.New(s.persesClient), - plugin.New(s.persesClient), + project.New(v1Client), + dashboard.New(v1Client), + ephemeraldashboard.New(v1Client), + datasource.New(v1Client), + globaldatasource.New(v1Client), + role.New(v1Client), + globalrole.New(v1Client), + rolebinding.New(v1Client), + globalrolebinding.New(v1Client), + variable.New(v1Client), + globalvariable.New(v1Client), + plugin.New(v1Client), + migrate.New(s.persesClient), } var allTools []*tools.Tool diff --git a/pkg/tools/migrate/migrate.go b/pkg/tools/migrate/migrate.go new file mode 100644 index 0000000..c83a25c --- /dev/null +++ b/pkg/tools/migrate/migrate.go @@ -0,0 +1,268 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migrate + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/perses/mcp-server/pkg/tools" + "github.com/perses/mcp-server/pkg/tools/resource" + apiClient "github.com/perses/perses/pkg/client/api" + "github.com/perses/perses/pkg/model/api" +) + +type migrate struct { + client apiClient.ClientInterface +} + +func New(client apiClient.ClientInterface) resource.Resource { + return &migrate{ + client: client, + } +} + +// Datasource strategy values offered in the elicitation form presented to the user. +const ( + strategyKeepReferences = "keep-hard-coded-references" + strategyPersesDefault = "use-perses-default-datasource" +) + +type MigrateDashboardInput struct { + GrafanaDashboard string `json:"grafanaDashboard" jsonschema:"Grafana dashboard JSON as a string"` + Input map[string]string `json:"input,omitempty" jsonschema:"Grafana input values used to resolve __inputs placeholders (e.g. DS_PROMETHEUS=my-datasource)"` +} + +func (m *migrate) GetTools() []*tools.Tool { + return []*tools.Tool{ + m.Migrate(), + } +} + +// useDefaultDatasourceElicitID identifies the input request/response used to ask the +// user whether the default datasource should be used during migration. +const useDefaultDatasourceElicitID = "useDefaultDatasource" + +func (m *migrate) Migrate() *tools.Tool { + tool := &mcp.Tool{ + Name: "perses_migrate_dashboard", + Description: "Convert a Grafana dashboard into a native Perses dashboard. " + + "Use this tool to translate exported Grafana dashboard JSON into the Perses dashboard model, " + + "The result is returned as JSON only and is NOT saved to Perses; " + + "use the dashboard create tool to persist it.", + Annotations: &mcp.ToolAnnotations{ + Title: "Migrates a Grafana dashboard to the Perses format", + ReadOnlyHint: true, + DestructiveHint: new(false), + IdempotentHint: true, + OpenWorldHint: new(false), + }, + InputSchema: &jsonschema.Schema{ + Type: tools.SchemaTypeObject, + Properties: map[string]*jsonschema.Schema{ + "grafanaDashboard": { + Type: tools.SchemaTypeString, + Description: "Grafana dashboard JSON as a string", + }, + "input": { + Type: tools.SchemaTypeObject, + Description: "Grafana input values used to resolve __inputs placeholders (e.g. DS_PROMETHEUS=my-datasource)", + AdditionalProperties: &jsonschema.Schema{ + Type: tools.SchemaTypeString, + }, + }, + }, + Required: []string{"grafanaDashboard"}, + }, + } + + handler := func(_ context.Context, req *mcp.CallToolRequest, input MigrateDashboardInput) (*mcp.CallToolResult, any, error) { //nolint:unparam + if input.GrafanaDashboard == "" { + return nil, nil, fmt.Errorf("grafana dashboard JSON cannot be empty") + } + + // When no human can be asked, this stays false: preserving the original datasource + // references is the lossless outcome. + useDefaultDatasource, chosenByUser := false, false + switch { + // The elicitation response must be checked before anything else: the SDK replays the + // original Arguments on the multi round-trip retry, so an argument-first branch would + // re-prompt the user on every retry until the retry budget is exhausted. + case hasUseDefaultDatasourceResponse(req): + useDefaultDatasource, chosenByUser = resolveUseDefaultDatasourceResponse(req), true + + // Nothing the caller sends can skip this prompt: the user always decides. + case clientSupportsFormElicitation(req): + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + useDefaultDatasourceElicitID: useDefaultDatasourceElicitParams(), + }, + }, nil, nil + } + + persesDashboard, err := m.client.Migrate(&api.Migrate{ + Input: input.Input, + GrafanaDashboard: json.RawMessage(input.GrafanaDashboard), + UseDefaultDatasource: useDefaultDatasource, + }) + if err != nil { + return nil, nil, fmt.Errorf("error migrating grafana dashboard: %w", err) + } + + text, err := json.Marshal(persesDashboard) + if err != nil { + return nil, nil, fmt.Errorf("error marshalling migrated dashboard: %w", err) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: datasourceStrategyNote(useDefaultDatasource, chosenByUser), + }, + &mcp.TextContent{ + Text: string(text), + }, + }, + }, nil, nil + } + + return &tools.Tool{ + MCPTool: tool, + IsWriteTool: false, + ResourceType: tools.MigrateResource, + RegisterWith: func(server *mcp.Server) { mcp.AddTool(server, tool, handler) }, + } +} + +func clientSupportsFormElicitation(req *mcp.CallToolRequest) bool { + if req == nil || req.Session == nil { + return false + } + params := req.Session.InitializeParams() + if params == nil { + return false + } + return supportsFormElicitation(params.Capabilities) +} + +// supportsFormElicitation reports whether the advertised capabilities include form +// elicitation. A client declaring neither mode predates the form/URL split and is assumed +// to support forms. +func supportsFormElicitation(capabilities *mcp.ClientCapabilities) bool { + if capabilities == nil || capabilities.Elicitation == nil { + return false + } + elicitation := capabilities.Elicitation + return elicitation.Form != nil || elicitation.URL == nil +} + +// datasourceStrategyNote returns a short human-readable note describing which datasource +// strategy was applied to the migrated dashboard, so the caller understands the outcome +// without diffing the JSON. It also records who made the choice, so a strategy applied +// without asking the user is visible rather than silent. +func datasourceStrategyNote(useDefaultDatasource, chosenByUser bool) string { + strategy := "preserved the original datasource references from the Grafana dashboard" + if useDefaultDatasource { + strategy = "replaced all panel datasources with the default Perses datasource" + } + if chosenByUser { + return fmt.Sprintf("Datasource strategy: %s (chosen by the user).", strategy) + } + return fmt.Sprintf("Datasource strategy: %s (server configuration or caller-provided; the user was not asked).", strategy) +} + +// useDefaultDatasourceElicitParams builds the elicitation request asking the user +// whether the default Perses datasource should be used for all panels. The user is the +// only one who can answer: there is deliberately no tool argument that pre-empts this. +func useDefaultDatasourceElicitParams() *mcp.ElicitParams { + return &mcp.ElicitParams{ + Mode: "form", + Message: "How should panel datasources be handled in the migrated dashboard?", + RequestedSchema: &jsonschema.Schema{ + Type: tools.SchemaTypeObject, + Properties: map[string]*jsonschema.Schema{ + useDefaultDatasourceElicitID: { + Type: tools.SchemaTypeString, + Title: "Datasource strategy", + Description: "This choice is applied to all panels in the migrated dashboard.", + Enum: []any{strategyKeepReferences, strategyPersesDefault}, + Extra: map[string]any{ + "enumNames": []any{ + "Preserve the original hard-coded datasource references from Grafana", + "Replace all original hard-coded datasource references with the Perses default datasource", + }, + }, + }, + }, + }, + } +} + +// hasUseDefaultDatasourceResponse reports whether the request carries the response to +// the default-datasource elicitation (i.e. the client fulfilled the input request). +func hasUseDefaultDatasourceResponse(req *mcp.CallToolRequest) bool { + if req == nil || req.Params == nil { + return false + } + _, ok := req.Params.InputResponses[useDefaultDatasourceElicitID] + return ok +} + +// resolveUseDefaultDatasourceResponse extracts the user's answer from the elicitation +// response. It returns false when the user declined or cancelled, or when the response +// is missing the expected value, so migration preserves datasource references. +// +// Both the current string form and the legacy boolean form are accepted, so clients that +// answered the previous boolean schema keep working. +func resolveUseDefaultDatasourceResponse(req *mcp.CallToolRequest) bool { + response, ok := req.Params.InputResponses[useDefaultDatasourceElicitID].(*mcp.ElicitResult) + if !ok || response == nil || response.Action != "accept" { + return false + } + switch value := response.Content[useDefaultDatasourceElicitID].(type) { + case string: + return value == strategyPersesDefault + case bool: + return value + default: + return false + } +} + +// List is not required for migrate +func (m *migrate) List() *tools.Tool { + return nil +} + +// Get is not required for migrate +func (m *migrate) Get() *tools.Tool { + return nil +} + +// Create is not required for migrate +func (m *migrate) Create() *tools.Tool { + return nil +} + +// Update is not required for migrate +func (m *migrate) Update() *tools.Tool { + return nil +} + +// Delete is not required for migrate +func (m *migrate) Delete() *tools.Tool { + return nil +} diff --git a/pkg/tools/migrate/migrate_test.go b/pkg/tools/migrate/migrate_test.go new file mode 100644 index 0000000..3ea38e4 --- /dev/null +++ b/pkg/tools/migrate/migrate_test.go @@ -0,0 +1,304 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migrate + +import ( + "strings" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func elicitResult(action string, content map[string]any) *mcp.ElicitResult { + return &mcp.ElicitResult{Action: action, Content: content} +} + +func requestWithResponse(result *mcp.ElicitResult) *mcp.CallToolRequest { + return &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + InputResponses: mcp.InputResponseMap{useDefaultDatasourceElicitID: result}, + }, + } +} + +func TestResolveUseDefaultDatasourceResponse(t *testing.T) { + testSuites := []struct { + title string + request *mcp.CallToolRequest + expected bool + }{ + { + title: "the perses-default value selects the default datasource", + request: requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: strategyPersesDefault})), + expected: true, + }, + { + title: "the keep-references value keeps the hard-coded references", + request: requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: strategyKeepReferences})), + expected: false, + }, + { + // Back-compat: clients that answered the previous boolean schema. + title: "legacy boolean true selects the default datasource", + request: requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: true})), + expected: true, + }, + { + title: "legacy boolean false keeps the original references", + request: requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: false})), + expected: false, + }, + { + // The SDK applies the schema Default before this point, but a client returning + // nil content bypasses that, so the fallback must still hold. + title: "accept with nil content falls back to preserve", + request: requestWithResponse(elicitResult("accept", nil)), + expected: false, + }, + { + title: "accept with empty content falls back to preserve", + request: requestWithResponse(elicitResult("accept", map[string]any{})), + expected: false, + }, + { + title: "decline falls back to keeping the references", + request: requestWithResponse(elicitResult("decline", map[string]any{useDefaultDatasourceElicitID: strategyPersesDefault})), + expected: false, + }, + { + title: "cancel falls back to preserve", + request: requestWithResponse(elicitResult("cancel", nil)), + expected: false, + }, + { + title: "unexpected value type falls back to preserve", + request: requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: 42})), + expected: false, + }, + } + + for _, test := range testSuites { + t.Run(test.title, func(t *testing.T) { + if got := resolveUseDefaultDatasourceResponse(test.request); got != test.expected { + t.Errorf("expected %v, got %v", test.expected, got) + } + }) + } +} + +func TestHasUseDefaultDatasourceResponse(t *testing.T) { + testSuites := []struct { + title string + request *mcp.CallToolRequest + expected bool + }{ + { + title: "nil request", + request: nil, + expected: false, + }, + { + title: "nil params", + request: &mcp.CallToolRequest{}, + expected: false, + }, + { + title: "no input responses", + request: &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{}}, + expected: false, + }, + { + title: "input response present", + request: requestWithResponse(elicitResult("accept", nil)), + expected: true, + }, + } + + for _, test := range testSuites { + t.Run(test.title, func(t *testing.T) { + if got := hasUseDefaultDatasourceResponse(test.request); got != test.expected { + t.Errorf("expected %v, got %v", test.expected, got) + } + }) + } +} + +// The SDK replays the original Arguments on a multi round-trip retry. If the handler +// checked anything from the arguments before the elicitation response, every retry would +// issue a fresh prompt until the retry budget is exhausted. This guards that ordering. +func TestElicitationResponseIsCheckedFirst(t *testing.T) { + request := requestWithResponse(elicitResult("accept", map[string]any{useDefaultDatasourceElicitID: strategyPersesDefault})) + + if !hasUseDefaultDatasourceResponse(request) { + t.Fatal("expected the elicitation response to be detected") + } + if got := resolveUseDefaultDatasourceResponse(request); !got { + t.Errorf("expected the user's answer to be honoured, got %v", got) + } +} + +func TestClientSupportsFormElicitation(t *testing.T) { + testSuites := []struct { + title string + capabilities *mcp.ClientCapabilities + expected bool + }{ + { + title: "no elicitation capability", + capabilities: &mcp.ClientCapabilities{}, + expected: false, + }, + { + title: "form elicitation", + capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}}, + expected: true, + }, + { + // A URL-only client would reject the form request later with + // `client does not support "form" elicitation`, so it must not be prompted. + title: "url-only elicitation is not enough", + capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + expected: false, + }, + { + title: "both modes", + capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{ + Form: &mcp.FormElicitationCapabilities{}, + URL: &mcp.URLElicitationCapabilities{}, + }}, + expected: true, + }, + { + // Predates the form/URL split: assumed to support forms. + title: "elicitation declared without a mode", + capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}}, + expected: true, + }, + } + + for _, test := range testSuites { + t.Run(test.title, func(t *testing.T) { + if got := supportsFormElicitation(test.capabilities); got != test.expected { + t.Errorf("expected %v, got %v", test.expected, got) + } + }) + } +} + +func TestClientSupportsFormElicitationNilSafety(t *testing.T) { + if clientSupportsFormElicitation(nil) { + t.Error("expected false for a nil request") + } + if clientSupportsFormElicitation(&mcp.CallToolRequest{}) { + t.Error("expected false for a request without a session") + } +} + +// The elicited property must not be Required: the SDK validates accepted content against +// this schema *before* applying defaults, so requiring it would make a client that accepts +// with empty content fail the entire tools/call instead of taking the default. +func TestUseDefaultDatasourceElicitParamsSchema(t *testing.T) { + params := useDefaultDatasourceElicitParams() + + schema, ok := params.RequestedSchema.(*jsonschema.Schema) + if !ok { + t.Fatalf("expected a *jsonschema.Schema, got %T", params.RequestedSchema) + } + if len(schema.Required) != 0 { + t.Errorf("expected no required properties, got %v", schema.Required) + } + + property := schema.Properties[useDefaultDatasourceElicitID] + if property == nil { + t.Fatalf("expected property %q to be present", useDefaultDatasourceElicitID) + } + + // Enums are only supported on string fields by the SDK. + if property.Type != "string" { + t.Errorf("expected a string property, got %q", property.Type) + } + + // enumNames must have exactly one label per enum value or the client rejects it. + enumNames, ok := property.Extra["enumNames"].([]any) + if !ok { + t.Fatalf("expected enumNames to be an array, got %T", property.Extra["enumNames"]) + } + if len(enumNames) != len(property.Enum) { + t.Errorf("expected %d enumNames to match %d enum values, got %d", len(property.Enum), len(property.Enum), len(enumNames)) + } +} + +// The form must offer exactly the two real choices. A Default would make the client render +// extra generic "Use default" and "Omit" rows, and Required would make an empty accept fail +// the whole tools/call, so neither may be set. +func TestUseDefaultDatasourceElicitParamsOffersOnlyTheTwoChoices(t *testing.T) { + params := useDefaultDatasourceElicitParams() + schema, ok := params.RequestedSchema.(*jsonschema.Schema) + if !ok { + t.Fatalf("expected a *jsonschema.Schema, got %T", params.RequestedSchema) + } + property := schema.Properties[useDefaultDatasourceElicitID] + if property == nil { + t.Fatalf("expected property %q to be present", useDefaultDatasourceElicitID) + } + + if property.Default != nil { + t.Errorf("expected no default, got %s", property.Default) + } + if len(property.Enum) != 2 { + t.Errorf("expected exactly 2 enum values, got %d: %v", len(property.Enum), property.Enum) + } + if property.Enum[0] != strategyKeepReferences || property.Enum[1] != strategyPersesDefault { + t.Errorf("expected [%q %q], got %v", strategyKeepReferences, strategyPersesDefault, property.Enum) + } +} + +func TestDatasourceStrategyNote(t *testing.T) { + testSuites := []struct { + title string + useDefaultDatasource bool + chosenByUser bool + contains string + }{ + { + title: "user chose the default datasource", + useDefaultDatasource: true, + chosenByUser: true, + contains: "chosen by the user", + }, + { + title: "user chose to preserve", + useDefaultDatasource: false, + chosenByUser: true, + contains: "chosen by the user", + }, + { + // A choice made without asking must be visible rather than silent. + title: "applied without asking the user", + useDefaultDatasource: false, + chosenByUser: false, + contains: "the user was not asked", + }, + } + + for _, test := range testSuites { + t.Run(test.title, func(t *testing.T) { + note := datasourceStrategyNote(test.useDefaultDatasource, test.chosenByUser) + if !strings.Contains(note, test.contains) { + t.Errorf("expected the note to contain %q, got %q", test.contains, note) + } + }) + } +} diff --git a/pkg/tools/tool.go b/pkg/tools/tool.go index 9752578..af6b3a8 100644 --- a/pkg/tools/tool.go +++ b/pkg/tools/tool.go @@ -16,9 +16,10 @@ package tools import "github.com/modelcontextprotocol/go-sdk/mcp" const ( - SchemaTypeObject = "object" - SchemaTypeString = "string" - SchemaTypeArray = "array" + SchemaTypeObject = "object" + SchemaTypeString = "string" + SchemaTypeArray = "array" + SchemaTypeBoolean = "boolean" ) const ( @@ -41,6 +42,7 @@ const ( VariableResource Resource = "variable" GlobalVariableResource Resource = "globalvariable" PluginResource Resource = "plugin" + MigrateResource Resource = "migrate" ) var ValidResources = []Resource{ @@ -56,6 +58,7 @@ var ValidResources = []Resource{ VariableResource, GlobalVariableResource, PluginResource, + MigrateResource, } // Tool represents an MCP tool with metadata about write access requirements