Skip to content

Commit 30857c2

Browse files
feat(tools/looker-run-dashboard): new run_dashboard tool (#1858)
## Description The run_dashboard tool will run the query associated with each tile of the dashboard and return the full set of query results. It enables the agent to answer questions like "Summarize this dashboard for me". --------- Co-authored-by: Yuan Teoh <[email protected]>
1 parent 7e7b572 commit 30857c2

File tree

9 files changed

+442
-2
lines changed

9 files changed

+442
-2
lines changed

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import (
127127
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquery"
128128
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquerysql"
129129
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl"
130+
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard"
130131
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook"
131132
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile"
132133
_ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql"

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1514,7 +1514,7 @@ func TestPrebuiltTools(t *testing.T) {
15141514
wantToolset: server.ToolsetConfigs{
15151515
"looker_tools": tools.ToolsetConfig{
15161516
Name: "looker_tools",
1517-
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"},
1517+
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"},
15181518
},
15191519
},
15201520
},

docs/en/how-to/connect-ide/looker_mcp.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ instance and create new saved content.
315315
1. **run_look**: Run a saved Look and return the data
316316
1. **make_look**: Create a saved Look in Looker and return the URL
317317
1. **get_dashboards**: Return the saved dashboards that match a title or description
318+
1. **run_dashbaord**: Run the queries associated with a dashboard and return the data
318319
1. **make_dashboard**: Create a saved dashboard in Looker and return the URL
319320
1. **add_dashboard_element**: Add a tile to a dashboard
320321

docs/en/reference/prebuilt-tools.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP.
386386
* `run_look`: Runs the query associated with a look.
387387
* `make_look`: Creates a new look.
388388
* `get_dashboards`: Searches for saved dashboards.
389+
* `run_dashboard`: Runs the queries associated with a dashboard.
389390
* `make_dashboard`: Creates a new dashboard.
390391
* `add_dashboard_element`: Adds a tile to a dashboard.
391392
* `health_pulse`: Test the health of a Looker instance.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
title: "looker-run-dashboard"
3+
type: docs
4+
weight: 1
5+
description: >
6+
"looker-run-dashboard" runs the queries associated with a dashboard.
7+
aliases:
8+
- /resources/tools/looker-run-dashboard
9+
---
10+
11+
## About
12+
13+
The `looker-run-dashboard` tool runs the queries associated with a
14+
dashboard.
15+
16+
It's compatible with the following sources:
17+
18+
- [looker](../../sources/looker.md)
19+
20+
`looker-run-dashboard` takes one parameter, the `dashboard_id`.
21+
22+
## Example
23+
24+
```yaml
25+
tools:
26+
run_dashboard:
27+
kind: looker-run-dashboard
28+
source: looker-source
29+
description: |
30+
run_dashboard Tool
31+
32+
This tools runs the query associated with each tile in a dashboard
33+
and returns the data in a JSON structure. It accepts the dashboard_id
34+
as the parameter.
35+
```
36+
37+
## Reference
38+
39+
| **field** | **type** | **required** | **description** |
40+
|-------------|:--------:|:------------:|----------------------------------------------------|
41+
| kind | string | true | Must be "looker-run-dashboard" |
42+
| source | string | true | Name of the source the SQL should execute on. |
43+
| description | string | true | Description of the tool that is passed to the LLM. |

internal/prebuiltconfigs/tools/looker.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,16 @@ tools:
663663
664664
The result of the get_dashboards tool is a list of json objects.
665665
666+
run_dashboard:
667+
kind: looker-run-dashboard
668+
source: looker-source
669+
description: |
670+
run_dashboard Tool
671+
672+
This tools runs the query associated with each tile in a dashboard
673+
and returns the data in a JSON structure. It accepts the dashboard_id
674+
as the parameter.
675+
666676
make_dashboard:
667677
kind: looker-make-dashboard
668678
source: looker-source
@@ -886,6 +896,7 @@ toolsets:
886896
- run_look
887897
- make_look
888898
- get_dashboards
899+
- run_dashboard
889900
- make_dashboard
890901
- add_dashboard_element
891902
- health_pulse
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package lookerrundashboard
15+
16+
import (
17+
"context"
18+
"encoding/json"
19+
"fmt"
20+
"sync"
21+
22+
yaml "github.com/goccy/go-yaml"
23+
"github.com/googleapis/genai-toolbox/internal/sources"
24+
lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker"
25+
"github.com/googleapis/genai-toolbox/internal/tools"
26+
"github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon"
27+
"github.com/googleapis/genai-toolbox/internal/util"
28+
29+
"github.com/looker-open-source/sdk-codegen/go/rtl"
30+
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
31+
)
32+
33+
const kind string = "looker-run-dashboard"
34+
35+
func init() {
36+
if !tools.Register(kind, newConfig) {
37+
panic(fmt.Sprintf("tool kind %q already registered", kind))
38+
}
39+
}
40+
41+
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
42+
actual := Config{Name: name}
43+
if err := decoder.DecodeContext(ctx, &actual); err != nil {
44+
return nil, err
45+
}
46+
return actual, nil
47+
}
48+
49+
type Config struct {
50+
Name string `yaml:"name" validate:"required"`
51+
Kind string `yaml:"kind" validate:"required"`
52+
Source string `yaml:"source" validate:"required"`
53+
Description string `yaml:"description" validate:"required"`
54+
AuthRequired []string `yaml:"authRequired"`
55+
}
56+
57+
// validate interface
58+
var _ tools.ToolConfig = Config{}
59+
60+
func (cfg Config) ToolConfigKind() string {
61+
return kind
62+
}
63+
64+
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
65+
// verify source exists
66+
rawS, ok := srcs[cfg.Source]
67+
if !ok {
68+
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
69+
}
70+
71+
// verify the source is compatible
72+
s, ok := rawS.(*lookersrc.Source)
73+
if !ok {
74+
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind)
75+
}
76+
77+
dashboardidParameter := tools.NewStringParameter("dashboard_id", "The id of the dashboard to run.")
78+
79+
parameters := tools.Parameters{
80+
dashboardidParameter,
81+
}
82+
83+
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters)
84+
85+
// finish tool setup
86+
return Tool{
87+
Name: cfg.Name,
88+
Kind: kind,
89+
Parameters: parameters,
90+
AuthRequired: cfg.AuthRequired,
91+
UseClientOAuth: s.UseClientOAuth,
92+
Client: s.Client,
93+
ApiSettings: s.ApiSettings,
94+
manifest: tools.Manifest{
95+
Description: cfg.Description,
96+
Parameters: parameters.Manifest(),
97+
AuthRequired: cfg.AuthRequired,
98+
},
99+
mcpManifest: mcpManifest,
100+
}, nil
101+
}
102+
103+
// validate interface
104+
var _ tools.Tool = Tool{}
105+
106+
type Tool struct {
107+
Name string `yaml:"name"`
108+
Kind string `yaml:"kind"`
109+
UseClientOAuth bool
110+
Client *v4.LookerSDK
111+
ApiSettings *rtl.ApiSettings
112+
AuthRequired []string `yaml:"authRequired"`
113+
Parameters tools.Parameters `yaml:"parameters"`
114+
manifest tools.Manifest
115+
mcpManifest tools.McpManifest
116+
}
117+
118+
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
119+
logger, err := util.LoggerFromContext(ctx)
120+
if err != nil {
121+
return nil, fmt.Errorf("unable to get logger from ctx: %s", err)
122+
}
123+
logger.DebugContext(ctx, "params = ", params)
124+
paramsMap := params.AsMap()
125+
126+
dashboard_id := paramsMap["dashboard_id"].(string)
127+
128+
sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken)
129+
if err != nil {
130+
return nil, fmt.Errorf("error getting sdk: %w", err)
131+
}
132+
dashboard, err := sdk.Dashboard(dashboard_id, "", t.ApiSettings)
133+
if err != nil {
134+
return nil, fmt.Errorf("error getting dashboard: %w", err)
135+
}
136+
137+
data := make(map[string]any)
138+
data["tiles"] = make([]any, 0)
139+
if dashboard.Title != nil {
140+
data["title"] = *dashboard.Title
141+
}
142+
if dashboard.Description != nil {
143+
data["description"] = *dashboard.Description
144+
}
145+
146+
channels := make([]<-chan map[string]any, len(*dashboard.DashboardElements))
147+
for i, element := range *dashboard.DashboardElements {
148+
channels[i] = tileQueryWorker(ctx, sdk, t.ApiSettings, i, element)
149+
}
150+
151+
for resp := range merge(channels...) {
152+
data["tiles"] = append(data["tiles"].([]any), resp)
153+
}
154+
155+
logger.DebugContext(ctx, "data = ", data)
156+
157+
return data, nil
158+
}
159+
160+
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
161+
return tools.ParseParams(t.Parameters, data, claims)
162+
}
163+
164+
func (t Tool) Manifest() tools.Manifest {
165+
return t.manifest
166+
}
167+
168+
func (t Tool) McpManifest() tools.McpManifest {
169+
return t.mcpManifest
170+
}
171+
172+
func (t Tool) Authorized(verifiedAuthServices []string) bool {
173+
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
174+
}
175+
176+
func (t Tool) RequiresClientAuthorization() bool {
177+
return t.UseClientOAuth
178+
}
179+
180+
func tileQueryWorker(ctx context.Context, sdk *v4.LookerSDK, options *rtl.ApiSettings, index int, element v4.DashboardElement) <-chan map[string]any {
181+
out := make(chan map[string]any)
182+
183+
go func() {
184+
defer close(out)
185+
186+
data := make(map[string]any)
187+
data["index"] = index
188+
if element.Title != nil {
189+
data["title"] = *element.Title
190+
}
191+
if element.TitleText != nil {
192+
data["title_text"] = *element.TitleText
193+
}
194+
if element.SubtitleText != nil {
195+
data["subtitle_text"] = *element.SubtitleText
196+
}
197+
if element.BodyText != nil {
198+
data["body_text"] = *element.BodyText
199+
}
200+
201+
var q v4.Query
202+
if element.Query != nil {
203+
data["element_type"] = "query"
204+
q = *element.Query
205+
} else if element.Look != nil {
206+
data["element_type"] = "look"
207+
q = *element.Look.Query
208+
} else {
209+
// Just a text element
210+
data["element_type"] = "text"
211+
out <- data
212+
return
213+
}
214+
215+
wq := v4.WriteQuery{
216+
Model: q.Model,
217+
View: q.View,
218+
Fields: q.Fields,
219+
Pivots: q.Pivots,
220+
Filters: q.Filters,
221+
Sorts: q.Sorts,
222+
QueryTimezone: q.QueryTimezone,
223+
Limit: q.Limit,
224+
}
225+
query_result, err := lookercommon.RunInlineQuery(ctx, sdk, &wq, "json", options)
226+
if err != nil {
227+
data["query_status"] = "error running query"
228+
out <- data
229+
return
230+
}
231+
var resp []any
232+
e := json.Unmarshal([]byte(query_result), &resp)
233+
if e != nil {
234+
data["query_status"] = "error parsing query result"
235+
out <- data
236+
return
237+
}
238+
data["query_status"] = "success"
239+
data["query_result"] = resp
240+
out <- data
241+
}()
242+
return out
243+
}
244+
245+
func merge(channels ...<-chan map[string]any) <-chan map[string]any {
246+
var wg sync.WaitGroup
247+
out := make(chan map[string]any)
248+
249+
output := func(c <-chan map[string]any) {
250+
for n := range c {
251+
out <- n
252+
}
253+
wg.Done()
254+
}
255+
wg.Add(len(channels))
256+
for _, c := range channels {
257+
go output(c)
258+
}
259+
260+
// Start a goroutine to close out once all the output goroutines are
261+
// done. This must start after the wg.Add call.
262+
go func() {
263+
wg.Wait()
264+
close(out)
265+
}()
266+
return out
267+
}

0 commit comments

Comments
 (0)