Skip to content

Commit 3debe81

Browse files
feat(tool/looker): add get_field_value_suggestions tool (#3696)
## Description This PR implements a new Looker MCP tool `get_field_value_suggestions` to expose the Looker suggestions API (`GET /models/{model}/views/{explore}/fields/{field}/suggestions`). ### Tool calls and frequency for a sample prompt **Prompt**: *"Get the query count from system activity history for completed runs where the source is 'scheduled'"* **Before (without suggestions tool)**: * `mcp_mylocalLookerWithOauth_get_models`: 1 time * `mcp_mylocalLookerWithOauth_get_explores`: 1 time * `mcp_mylocalLookerWithOauth_get_dimensions`: 1 time * `mcp_mylocalLookerWithOauth_get_measures`: 1 time * `mcp_mylocalLookerWithOauth_query`: 5 times (explored source values, checked status strings, corrected bad filters, executed query, and verified output) **After (with suggestions tool)**: * `mcp_looker-local_get_models`: 1 time * `mcp_looker-local_get_explores`: 1 time * `mcp_looker-local_get_dimensions`: 1 time * `mcp_looker-local_get_measures`: 1 time * `mcp_looker-local_get_field_value_suggestions`: 2 times (retrieved "scheduled_task" and "complete" values) * `mcp_looker-local_query`: 1 time (executed final query with correct filters directly) ### Summary of the Solution - Created the `get_field_value_suggestions` tool wrapping the Looker SDK. - Exposed the `suggestable` boolean property in the `get_dimensions` tool metadata output, allowing LLM planning engines to identify which dimensions support suggestions lookup. - Wrapped the returned suggestions list in a JSON object (`{"suggestions": [...]}`) instead of a raw JSON array to satisfy strict client schema validation requirements and prevent `structuredContent` parsing errors. - Tuned parameter descriptions and prompt suggestions inside the `query` tool and documentation to guide the LLM planning engine to use this tool when unsure of valid values. - Added comprehensive integration tests, unit tests, and validation test cases. --- 🛠️ Fixes #3695 🦕 --------- Co-authored-by: Mike DeAngelo <drstrangelove@google.com>
1 parent d0a8f14 commit 3debe81

10 files changed

Lines changed: 477 additions & 9 deletions

File tree

cmd/internal/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2024,7 +2024,7 @@ func TestPrebuiltTools(t *testing.T) {
20242024
wantGroups: server.GroupConfigs{
20252025
"looker_tools": group.GroupConfig{
20262026
Name: "looker_tools",
2027-
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", "add_dashboard_filter", "generate_embed_url"},
2027+
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "get_field_value_suggestions", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url"},
20282028
},
20292029
},
20302030
},

cmd/internal/imports.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ import (
227227
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetdashboards"
228228
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetdimensions"
229229
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetexplores"
230+
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetfieldvaluesuggestions"
230231
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetfilters"
231232
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetgitbranch"
232233
_ "github.com/googleapis/mcp-toolbox/internal/tools/looker/lookergetlookmltests"

docs/en/integrations/looker/prebuilt-configs/looker.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ description: "Details of the Looker prebuilt configuration."
2626
* `get_measures`: Retrieves the list of measures in an explore.
2727
* `get_filters`: Retrieves the list of filters in an explore.
2828
* `get_parameters`: Retrieves the list of parameters in an explore.
29+
* `get_field_value_suggestions`: Retrieves distinct value suggestions for a field.
2930
* `query`: Runs a query against the LookML model.
3031
* `query_sql`: Generates the SQL for a query.
3132
* `query_url`: Generates a URL for a query in Looker.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
title: "looker-get-field-value-suggestions"
3+
type: docs
4+
weight: 1
5+
description: >
6+
A "looker-get-field-value-suggestions" tool retrieves distinct value suggestions
7+
for a given field in an explore.
8+
---
9+
10+
## About
11+
12+
The `looker-get-field-value-suggestions` tool retrieves distinct value suggestions for a given field in an explore.
13+
14+
## Compatible Sources
15+
16+
{{< compatible-sources >}}
17+
18+
## Parameters
19+
20+
| **field** | **type** | **required** | **description** |
21+
| --------- | :------: | :----------: | --------------------------------------------------- |
22+
| model | string | true | The name of the LookML model. |
23+
| explore | string | true | The name of the explore containing the field. |
24+
| field | string | true | The name of the field to get suggestions for. |
25+
| term | string | false | Optional search term pattern to filter suggestions. |
26+
| filters | object | false | Optional filters to enable conditional suggestions. |
27+
28+
## Example
29+
30+
```yaml
31+
kind: tool
32+
name: get_field_value_suggestions
33+
type: looker-get-field-value-suggestions
34+
source: looker-source
35+
description: |
36+
This tool retrieves distinct value suggestions for a field, facilitating accurate filtering in downstream queries.
37+
38+
Required Parameters:
39+
- model: The name of the LookML model, obtained from `get_models`.
40+
- explore: The name of the explore containing the field, obtained from `get_explores`.
41+
- field: The name of the field to get suggestions for, obtained from `get_dimensions`.
42+
43+
Optional Parameters:
44+
- term: Optional search term pattern to filter suggestions. Evaluated as `%term%`.
45+
- filters: Optional filters to enable conditional suggestions (restricting suggestions based on other field values), represented as a map of field names to filter expressions, e.g., `{"users.state": "CA", "users.age": ">=60"}`.
46+
47+
Output:
48+
- A JSON object with a "suggestions" key containing an array of string values representing suggestions.
49+
```
50+
51+
## Output Format
52+
53+
The output is a JSON object with a "suggestions" key containing an array of string values.
54+
55+
Example:
56+
57+
```json
58+
{
59+
"suggestions": ["CA", "NY", "TX", "WA"]
60+
}
61+
```
62+
63+
## Reference
64+
65+
| **field** | **type** | **required** | **description** |
66+
| ----------- | :------: | :----------: | -------------------------------------------------- |
67+
| type | string | true | Must be "looker-get-field-value-suggestions". |
68+
| source | string | true | Name of the source Looker instance. |
69+
| description | string | true | Description of the tool that is passed to the LLM. |

internal/prebuiltconfigs/tools/looker.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ description: |
6969
that can be used directly as filters for that dimension.
7070
- If a `suggest_explore` and `suggest_dimension` are provided, you can query
7171
that specified explore and dimension to retrieve a list of valid filter values.
72+
- If a dimension includes `"suggestable": true`, you can retrieve its valid value suggestions by calling the 'get_field_value_suggestions' tool, passing the dimension's 'name' as the 'field' parameter, alongside the explore and model names.
7273
---
7374
kind: tool
7475
name: get_measures
@@ -123,6 +124,25 @@ description: |
123124
- explore_name (required): The name of the explore within the model, obtained from `get_explores`.
124125
---
125126
kind: tool
127+
name: get_field_value_suggestions
128+
type: looker-get-field-value-suggestions
129+
source: looker-source
130+
description: |
131+
This tool retrieves distinct value suggestions for a field, facilitating accurate filtering in downstream queries.
132+
133+
Required Parameters:
134+
- model: The name of the LookML model, obtained from `get_models`.
135+
- explore: The name of the explore containing the field, obtained from `get_explores`.
136+
- field: The name of the field to get suggestions for (obtained from a dimension where 'suggestable' is true in the 'get_dimensions' output).
137+
138+
Optional Parameters:
139+
- term: Optional search term pattern to filter suggestions. Evaluated as `%term%`.
140+
- filters: Optional filters to enable conditional suggestions, represented as a map of field names to filter expressions, e.g., `{"users.state": "CA"}`.
141+
142+
Output:
143+
- A JSON object with a "suggestions" key containing an array of string values representing suggestions.
144+
---
145+
kind: tool
126146
name: query
127147
type: looker-query
128148
source: looker-source
@@ -140,6 +160,7 @@ description: |
140160
- Do not quote field names.
141161
- Use `not null` instead of `-NULL`.
142162
- If a value contains a comma, enclose it in single quotes (e.g., "'New York, NY'").
163+
- To retrieve valid filter values for a suggestible field, use the 'get_field_value_suggestions' tool.
143164
- filter_expression: A Looker expression filter string (custom filter). This allows complex logic and comparing fields.
144165
- Reference fields using `${view.field_name}` syntax.
145166
- Supports logical operators (`AND`, `OR`, `NOT`) and comparison operators.
@@ -840,6 +861,7 @@ tools:
840861
- get_measures
841862
- get_filters
842863
- get_parameters
864+
- get_field_value_suggestions
843865
- query
844866
- query_sql
845867
- query_url

internal/tools/looker/lookercommon/lookercommon.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ func ExtractLookerFieldProperties(ctx context.Context, fields *[]v4.LookmlModelE
8080
if v.Synonyms != nil && len(*v.Synonyms) > 0 {
8181
vMap["synonyms"] = *v.Synonyms
8282
}
83+
if v.Suggestable != nil {
84+
vMap["suggestable"] = *v.Suggestable
85+
}
8386
if v.Suggestable != nil && *v.Suggestable {
8487
if v.Suggestions != nil && len(*v.Suggestions) > 0 {
8588
vMap["suggestions"] = *v.Suggestions
@@ -123,7 +126,9 @@ func GetQueryParameters() parameters.Parameters {
123126
"(e.g. \"view.field\") and values are filter expressions or "+
124127
"parameter values. Pass values bare — do not wrap them in extra "+
125128
"quote characters. For LookML `parameter` fields, use the raw "+
126-
"allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
129+
"allowed_value (e.g. `first_touch`), not `\"first_touch\"`."+
130+
" To retrieve valid filter values for a suggestible field, "+
131+
"use the 'get_field_value_suggestions' tool.",
127132
"",
128133
parameters.WithMapDefault(map[string]any{}),
129134
)

internal/tools/looker/lookercommon/lookercommon_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func TestExtractLookerFieldProperties(t *testing.T) {
6363
"label": "Dimension Label",
6464
"label_short": "Dim Label",
6565
"description": "This is a dimension description",
66+
"suggestable": true,
6667
"suggest_explore": "explore",
6768
"suggest_dimension": "dimension",
6869
"suggestions": []string{"foo", "bar", "baz"},
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2026 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 lookergetfieldvaluesuggestions
15+
16+
import (
17+
"context"
18+
"fmt"
19+
"net/http"
20+
"strings"
21+
22+
yaml "github.com/goccy/go-yaml"
23+
"github.com/googleapis/mcp-toolbox/internal/tools"
24+
"github.com/googleapis/mcp-toolbox/internal/tools/looker/lookercommon"
25+
"github.com/googleapis/mcp-toolbox/internal/util"
26+
"github.com/googleapis/mcp-toolbox/internal/util/parameters"
27+
28+
"github.com/looker-open-source/sdk-codegen/go/rtl"
29+
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
30+
)
31+
32+
const resourceType string = "looker-get-field-value-suggestions"
33+
34+
func init() {
35+
if !tools.Register(resourceType, newConfig) {
36+
panic(fmt.Sprintf("tool type %q already registered", resourceType))
37+
}
38+
}
39+
40+
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
41+
actual := Config{ConfigBase: tools.ConfigBase{Name: name}}
42+
if err := decoder.DecodeContext(ctx, &actual); err != nil {
43+
return nil, err
44+
}
45+
return actual, nil
46+
}
47+
48+
type compatibleSource interface {
49+
UseClientAuthorization() bool
50+
GetAuthTokenHeaderName() string
51+
LookerApiSettings() *rtl.ApiSettings
52+
GetLookerSDK(context.Context, string) (*v4.LookerSDK, error)
53+
}
54+
55+
type Config struct {
56+
tools.ConfigBase `yaml:",inline"`
57+
Type string `yaml:"type" validate:"required"`
58+
Source string `yaml:"source" validate:"required"`
59+
Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"`
60+
}
61+
62+
// validate interface
63+
var _ tools.ToolConfig = Config{}
64+
65+
func (cfg Config) ToolConfigType() string {
66+
return resourceType
67+
}
68+
69+
func (cfg Config) Initialize(context.Context) (tools.Tool, error) {
70+
if cfg.Description == "" {
71+
return nil, fmt.Errorf("description is required for tool %q", cfg.Name)
72+
}
73+
74+
params := lookercommon.GetFieldParameters()
75+
76+
// Add field value suggestion specific parameters
77+
fieldParam := parameters.NewStringParameter("field", "The name of the field to get suggestions for.")
78+
termParam := parameters.NewStringParameter("term", "Optional search term pattern.", parameters.WithStringRequired(false))
79+
filtersParam := parameters.NewMapParameter("filters", "Optional filters to enable conditional suggestions (restricting suggestions based on other field values).", "", parameters.WithMapDefault(map[string]any{}))
80+
81+
params = append(params, fieldParam, termParam, filtersParam)
82+
83+
return Tool{
84+
BaseTool: tools.NewBaseTool(
85+
cfg,
86+
tools.GetAnnotationsOrDefault(cfg.Annotations, tools.NewReadOnlyAnnotations),
87+
tools.Manifest{Description: cfg.Description, Parameters: params.Manifest(), AuthRequired: cfg.AuthRequired},
88+
params,
89+
),
90+
}, nil
91+
}
92+
93+
// validate interface
94+
var _ tools.Tool = Tool{}
95+
96+
type Tool struct {
97+
tools.BaseTool[Config]
98+
}
99+
100+
func (t Tool) ToConfig() tools.ToolConfig {
101+
return t.Cfg
102+
}
103+
104+
func (t Tool) Invoke(ctx context.Context, primitiveMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, util.ToolboxError) {
105+
source, err := tools.GetCompatibleSource[compatibleSource](primitiveMgr, t.Cfg.Source, t.Cfg.Name, t.Cfg.Type)
106+
if err != nil {
107+
return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err)
108+
}
109+
110+
logger, err := util.LoggerFromContext(ctx)
111+
if err != nil {
112+
return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err)
113+
}
114+
115+
mapParams := params.AsMap()
116+
117+
model, ok := mapParams["model"].(string)
118+
if !ok {
119+
return nil, util.NewAgentError("model is required and must be a string", nil)
120+
}
121+
122+
explore, ok := mapParams["explore"].(string)
123+
if !ok {
124+
return nil, util.NewAgentError("explore is required and must be a string", nil)
125+
}
126+
127+
field, ok := mapParams["field"].(string)
128+
if !ok {
129+
return nil, util.NewAgentError("field is required and must be a string", nil)
130+
}
131+
132+
var termPtr *string
133+
if val, ok := mapParams["term"].(string); ok && val != "" {
134+
termPtr = &val
135+
}
136+
137+
filters, _ := mapParams["filters"].(map[string]any)
138+
139+
sdk, err := source.GetLookerSDK(ctx, string(accessToken))
140+
if err != nil {
141+
return nil, util.NewClientServerError("error getting Looker SDK", http.StatusInternalServerError, err)
142+
}
143+
144+
req := v4.RequestModelFieldnameSuggestions{
145+
ModelName: model,
146+
ViewName: explore, // Map 'explore' back to 'ViewName'
147+
FieldName: field,
148+
Term: termPtr,
149+
}
150+
if len(filters) > 0 {
151+
var f interface{} = filters
152+
req.Filters = &f
153+
}
154+
155+
resp, err := sdk.ModelFieldnameSuggestions(req, source.LookerApiSettings())
156+
if err != nil {
157+
if strings.Contains(err.Error(), "status=401") {
158+
return nil, util.NewClientServerError("unauthorized error", http.StatusUnauthorized, err)
159+
}
160+
return nil, util.ProcessGeneralError(err)
161+
}
162+
163+
if resp.Error != nil && *resp.Error != "" {
164+
return nil, util.NewAgentError(fmt.Sprintf("Looker API error: %s", *resp.Error), nil)
165+
}
166+
167+
logger.DebugContext(ctx, "suggestions = ", resp.Suggestions)
168+
169+
if resp.Suggestions == nil {
170+
return map[string]any{"suggestions": []string{}}, nil
171+
}
172+
173+
return map[string]any{"suggestions": *resp.Suggestions}, nil
174+
}
175+
176+
func (t Tool) RequiresClientAuthorization(primitiveMgr tools.SourceProvider) (bool, error) {
177+
source, err := tools.GetCompatibleSource[compatibleSource](primitiveMgr, t.Cfg.Source, t.Cfg.Name, t.Cfg.Type)
178+
if err != nil {
179+
return false, err
180+
}
181+
return source.UseClientAuthorization(), nil
182+
}
183+
184+
func (t Tool) GetAuthTokenHeaderName(primitiveMgr tools.SourceProvider) (string, error) {
185+
source, err := tools.GetCompatibleSource[compatibleSource](primitiveMgr, t.Cfg.Source, t.Cfg.Name, t.Cfg.Type)
186+
if err != nil {
187+
return "", err
188+
}
189+
return source.GetAuthTokenHeaderName(), nil
190+
}

0 commit comments

Comments
 (0)