diff --git a/cmd/root.go b/cmd/root.go index e0bb46c642..62703760b2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -154,6 +154,7 @@ import ( _ "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/looker/lookervalidateproject" _ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql" _ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbsql" _ "github.com/googleapis/genai-toolbox/internal/tools/mongodb/mongodbaggregate" diff --git a/cmd/root_test.go b/cmd/root_test.go index 6036c9c478..d15116554e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1613,7 +1613,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", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url", "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", "add_dashboard_filter", "generate_embed_url", "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", "validate_project","get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"}, }, }, }, diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index b340ac055a..1f098b85b8 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -434,6 +434,7 @@ See [Usage Examples](../reference/cli.md#examples). * `create_project_file`: Create a new LookML file. * `update_project_file`: Update an existing LookML file. * `delete_project_file`: Delete a LookML file. + * `validate_project`: Check the syntax of a LookML project. * `get_connections`: Get the available connections in a Looker instance. * `get_connection_schemas`: Get the available schemas in a connection. * `get_connection_databases`: Get the available databases in a connection. diff --git a/docs/en/resources/tools/looker/looker-validate-project.md b/docs/en/resources/tools/looker/looker-validate-project.md new file mode 100644 index 0000000000..956588b11d --- /dev/null +++ b/docs/en/resources/tools/looker/looker-validate-project.md @@ -0,0 +1,47 @@ +--- +title: "looker-validate-project" +type: docs +weight: 1 +description: > + A "looker-validate-project" tool checks the syntax of a LookML project and reports any errors +aliases: +- /resources/tools/looker-validate-project +--- + +## About + +A "looker-validate-project" tool checks the syntax of a LookML project and reports any errors + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-validate-project` accepts a project_id parameter. + +## Example + +```yaml +tools: + validate_project: + kind: looker-validate-project + source: looker-source + description: | + This tool checks a LookML project for syntax errors. + + Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first. + + Parameters: + - project_id (required): The unique ID of the LookML project. + + Output: + A list of error details including the file path and line number, and also a list of models + that are not currently valid due to LookML errors. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "looker-validate-project". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/internal/prebuiltconfigs/tools/looker.yaml b/internal/prebuiltconfigs/tools/looker.yaml index 442cd11106..c6bbd51c56 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -959,6 +959,21 @@ tools: Output: A confirmation message upon successful file deletion. + validate_project: + kind: looker-validate-project + source: looker-source + description: | + This tool checks a LookML project for syntax errors. + + Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first. + + Parameters: + - project_id (required): The unique ID of the LookML project. + + Output: + A list of error details including the file path and line number, and also a list of models + that are not currently valid due to LookML errors. + get_connections: kind: looker-get-connections source: looker-source @@ -1072,6 +1087,7 @@ toolsets: - create_project_file - update_project_file - delete_project_file + - validate_project - get_connections - get_connection_schemas - get_connection_databases diff --git a/internal/tools/looker/lookervalidateproject/lookervalidateproject.go b/internal/tools/looker/lookervalidateproject/lookervalidateproject.go new file mode 100644 index 0000000000..dba78a8b77 --- /dev/null +++ b/internal/tools/looker/lookervalidateproject/lookervalidateproject.go @@ -0,0 +1,173 @@ +// 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 lookervalidateproject + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + "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/googleapis/genai-toolbox/internal/util/parameters" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-validate-project" + +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 compatibleSource interface { + UseClientAuthorization() bool + GetAuthTokenHeaderName() string + LookerClient() *v4.LookerSDK + LookerApiSettings() *rtl.ApiSettings +} + +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"` + Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"` +} + +// 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) { + projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project to validate") + params := parameters.Parameters{projectIdParameter} + + annotations := cfg.Annotations + if annotations == nil { + readOnlyHint := true + annotations = &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyHint, + } + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + // finish tool setup + return Tool{ + Config: cfg, + Parameters: params, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: params.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Config + Parameters parameters.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Kind) + if err != nil { + return nil, err + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + + sdk, err := lookercommon.GetLookerSDK(source.UseClientAuthorization(), source.LookerApiSettings(), source.LookerClient(), accessToken) + if err != nil { + return nil, fmt.Errorf("error getting sdk: %w", err) + } + + mapParams := params.AsMap() + projectId, ok := mapParams["project_id"].(string) + if !ok { + return nil, fmt.Errorf("'project_id' must be a string, got %T", mapParams["project_id"]) + } + + resp, err := sdk.ValidateProject(projectId, "", source.LookerApiSettings()) + if err != nil { + return nil, fmt.Errorf("error making validate_project request: %s", err) + } + + logger.DebugContext(ctx, "Got response of %v\n", resp) + + return resp, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.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) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Kind) + if err != nil { + return false, err + } + return source.UseClientAuthorization(), nil +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Kind) + if err != nil { + return "", err + } + return source.GetAuthTokenHeaderName(), nil +} diff --git a/internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go b/internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go new file mode 100644 index 0000000000..e4a9d4e308 --- /dev/null +++ b/internal/tools/looker/lookervalidateproject/lookervalidateproject_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 lookervalidateproject_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/lookervalidateproject" +) + +func TestParseFromYamlLookerValidateProject(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-validate-project + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-validate-project", + 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 TestFailParseFromYamlLookerValidateProject(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-validate-project + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-validate-project\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-validate-project\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) + } + }) + } + +}