diff --git a/cmd/root.go b/cmd/root.go index bbb3495d8acf..226713e3a830 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -127,6 +127,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquery" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquerysql" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql" diff --git a/cmd/root_test.go b/cmd/root_test.go index ee3b6750be4b..af53a0262f35 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1514,7 +1514,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "looker_tools": tools.ToolsetConfig{ Name: "looker_tools", - ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "make_dashboard", "add_dashboard_element", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"}, + ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"}, }, }, }, diff --git a/docs/en/how-to/connect-ide/looker_mcp.md b/docs/en/how-to/connect-ide/looker_mcp.md index 06163cc8747e..42e10234a021 100644 --- a/docs/en/how-to/connect-ide/looker_mcp.md +++ b/docs/en/how-to/connect-ide/looker_mcp.md @@ -315,6 +315,7 @@ instance and create new saved content. 1. **run_look**: Run a saved Look and return the data 1. **make_look**: Create a saved Look in Looker and return the URL 1. **get_dashboards**: Return the saved dashboards that match a title or description +1. **run_dashbaord**: Run the queries associated with a dashboard and return the data 1. **make_dashboard**: Create a saved dashboard in Looker and return the URL 1. **add_dashboard_element**: Add a tile to a dashboard diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index e51d2ce9cb6d..c124d4eec688 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -386,6 +386,7 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP. * `run_look`: Runs the query associated with a look. * `make_look`: Creates a new look. * `get_dashboards`: Searches for saved dashboards. + * `run_dashboard`: Runs the queries associated with a dashboard. * `make_dashboard`: Creates a new dashboard. * `add_dashboard_element`: Adds a tile to a dashboard. * `health_pulse`: Test the health of a Looker instance. diff --git a/docs/en/resources/tools/looker/looker-run-dashboard.md b/docs/en/resources/tools/looker/looker-run-dashboard.md new file mode 100644 index 000000000000..1fb117c796b1 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-run-dashboard.md @@ -0,0 +1,43 @@ +--- +title: "looker-run-dashboard" +type: docs +weight: 1 +description: > + "looker-run-dashboard" runs the queries associated with a dashboard. +aliases: +- /resources/tools/looker-run-dashboard +--- + +## About + +The `looker-run-dashboard` tool runs the queries associated with a +dashboard. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-run-dashboard` takes one parameter, the `dashboard_id`. + +## Example + +```yaml +tools: + run_dashboard: + kind: looker-run-dashboard + source: looker-source + description: | + run_dashboard Tool + + This tools runs the query associated with each tile in a dashboard + and returns the data in a JSON structure. It accepts the dashboard_id + as the parameter. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "looker-run-dashboard" | +| source | string | true | Name of the source the SQL should execute on. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/internal/prebuiltconfigs/tools/looker.yaml b/internal/prebuiltconfigs/tools/looker.yaml index 3e049a58e8c0..3bb7b66963f6 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -663,6 +663,16 @@ tools: The result of the get_dashboards tool is a list of json objects. + run_dashboard: + kind: looker-run-dashboard + source: looker-source + description: | + run_dashboard Tool + + This tools runs the query associated with each tile in a dashboard + and returns the data in a JSON structure. It accepts the dashboard_id + as the parameter. + make_dashboard: kind: looker-make-dashboard source: looker-source @@ -886,6 +896,7 @@ toolsets: - run_look - make_look - get_dashboards + - run_dashboard - make_dashboard - add_dashboard_element - health_pulse diff --git a/internal/tools/looker/lookerrundashboard/lookerrundashboard.go b/internal/tools/looker/lookerrundashboard/lookerrundashboard.go new file mode 100644 index 000000000000..bdb5d9e823e5 --- /dev/null +++ b/internal/tools/looker/lookerrundashboard/lookerrundashboard.go @@ -0,0 +1,267 @@ +// Copyright 2025 Google LLC +// +// 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 lookerrundashboard + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + "github.com/googleapis/genai-toolbox/internal/util" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-run-dashboard" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + dashboardidParameter := tools.NewStringParameter("dashboard_id", "The id of the dashboard to run.") + + parameters := tools.Parameters{ + dashboardidParameter, + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + logger.DebugContext(ctx, "params = ", params) + paramsMap := params.AsMap() + + dashboard_id := paramsMap["dashboard_id"].(string) + + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken) + if err != nil { + return nil, fmt.Errorf("error getting sdk: %w", err) + } + dashboard, err := sdk.Dashboard(dashboard_id, "", t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error getting dashboard: %w", err) + } + + data := make(map[string]any) + data["tiles"] = make([]any, 0) + if dashboard.Title != nil { + data["title"] = *dashboard.Title + } + if dashboard.Description != nil { + data["description"] = *dashboard.Description + } + + channels := make([]<-chan map[string]any, len(*dashboard.DashboardElements)) + for i, element := range *dashboard.DashboardElements { + channels[i] = tileQueryWorker(ctx, sdk, t.ApiSettings, i, element) + } + + for resp := range merge(channels...) { + data["tiles"] = append(data["tiles"].([]any), resp) + } + + logger.DebugContext(ctx, "data = ", data) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} + +func tileQueryWorker(ctx context.Context, sdk *v4.LookerSDK, options *rtl.ApiSettings, index int, element v4.DashboardElement) <-chan map[string]any { + out := make(chan map[string]any) + + go func() { + defer close(out) + + data := make(map[string]any) + data["index"] = index + if element.Title != nil { + data["title"] = *element.Title + } + if element.TitleText != nil { + data["title_text"] = *element.TitleText + } + if element.SubtitleText != nil { + data["subtitle_text"] = *element.SubtitleText + } + if element.BodyText != nil { + data["body_text"] = *element.BodyText + } + + var q v4.Query + if element.Query != nil { + data["element_type"] = "query" + q = *element.Query + } else if element.Look != nil { + data["element_type"] = "look" + q = *element.Look.Query + } else { + // Just a text element + data["element_type"] = "text" + out <- data + return + } + + wq := v4.WriteQuery{ + Model: q.Model, + View: q.View, + Fields: q.Fields, + Pivots: q.Pivots, + Filters: q.Filters, + Sorts: q.Sorts, + QueryTimezone: q.QueryTimezone, + Limit: q.Limit, + } + query_result, err := lookercommon.RunInlineQuery(ctx, sdk, &wq, "json", options) + if err != nil { + data["query_status"] = "error running query" + out <- data + return + } + var resp []any + e := json.Unmarshal([]byte(query_result), &resp) + if e != nil { + data["query_status"] = "error parsing query result" + out <- data + return + } + data["query_status"] = "success" + data["query_result"] = resp + out <- data + }() + return out +} + +func merge(channels ...<-chan map[string]any) <-chan map[string]any { + var wg sync.WaitGroup + out := make(chan map[string]any) + + output := func(c <-chan map[string]any) { + for n := range c { + out <- n + } + wg.Done() + } + wg.Add(len(channels)) + for _, c := range channels { + go output(c) + } + + // Start a goroutine to close out once all the output goroutines are + // done. This must start after the wg.Add call. + go func() { + wg.Wait() + close(out) + }() + return out +} diff --git a/internal/tools/looker/lookerrundashboard/lookerrundashboard_test.go b/internal/tools/looker/lookerrundashboard/lookerrundashboard_test.go new file mode 100644 index 000000000000..6726e045450b --- /dev/null +++ b/internal/tools/looker/lookerrundashboard/lookerrundashboard_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 Google LLC +// +// 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 lookerrundashboard_test + +import ( + "strings" + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard" +) + +func TestParseFromYamlLookerRunDashboard(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: looker-run-dashboard + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-run-dashboard", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } + +} + +func TestFailParseFromYamlLookerRunDashboard(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "Invalid method", + in: ` + tools: + example_tool: + kind: looker-run-dashboard + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-run-dashboard\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-run-dashboard\n> 4 | method: GOT\n ^\n 5 | source: my-instance", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } + +} diff --git a/tests/looker/looker_integration_test.go b/tests/looker/looker_integration_test.go index a27722cc5052..653bbca5fccd 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -71,7 +71,7 @@ func getLookerVars(t *testing.T) map[string]any { func TestLooker(t *testing.T) { sourceConfig := getLookerVars(t) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")