Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
},
Expand Down
1 change: 1 addition & 0 deletions docs/en/how-to/connect-ide/looker_mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/en/reference/prebuilt-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions docs/en/resources/tools/looker/looker-run-dashboard.md
Original file line number Diff line number Diff line change
@@ -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. |
11 changes: 11 additions & 0 deletions internal/prebuiltconfigs/tools/looker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -886,6 +896,7 @@ toolsets:
- run_look
- make_look
- get_dashboards
- run_dashboard
- make_dashboard
- add_dashboard_element
- health_pulse
Expand Down
267 changes: 267 additions & 0 deletions internal/tools/looker/lookerrundashboard/lookerrundashboard.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading