From a91553fcca31589436dd132a5c1676fb035bfc98 Mon Sep 17 00:00:00 2001 From: Mike DeAngelo Date: Tue, 10 Feb 2026 17:06:29 -0500 Subject: [PATCH] feat(tools/looker): tools to list/create/delete directories within a LookML project. These tools only work on 26.4 and later. --- cmd/root.go | 3 + docs/en/reference/prebuilt-tools.md | 3 + .../looker/looker-create-project-directory.md | 44 +++++ .../looker/looker-delete-project-directory.md | 44 +++++ .../looker/looker-get-project-directories.md | 43 +++++ internal/prebuiltconfigs/tools/looker.yaml | 45 +++++ .../tools/looker/lookercommon/lookercommon.go | 33 +++- .../lookercreateprojectdirectory.go | 175 +++++++++++++++++ .../lookercreateprojectdirectory_test.go | 108 +++++++++++ .../lookerdeleteprojectdirectory.go | 175 +++++++++++++++++ .../lookerdeleteprojectdirectory_test.go | 108 +++++++++++ .../lookergetprojectdirectories.go | 178 ++++++++++++++++++ .../lookergetprojectdirectories_test.go | 108 +++++++++++ tests/looker/looker_integration_test.go | 89 +++++++++ 14 files changed, 1155 insertions(+), 1 deletion(-) create mode 100644 docs/en/resources/tools/looker/looker-create-project-directory.md create mode 100644 docs/en/resources/tools/looker/looker-delete-project-directory.md create mode 100644 docs/en/resources/tools/looker/looker-get-project-directories.md create mode 100644 internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory.go create mode 100644 internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory_test.go create mode 100644 internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory.go create mode 100644 internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory_test.go create mode 100644 internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories.go create mode 100644 internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories_test.go diff --git a/cmd/root.go b/cmd/root.go index 5e59997211..ceae9b86f0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -132,7 +132,9 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardelement" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardfilter" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerconversationalanalytics" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectdirectory" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectfile" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteprojectdirectory" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdevmode" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergenerateembedurl" @@ -149,6 +151,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetmeasures" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetmodels" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetparameters" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojectdirectories" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojectfiles" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojects" diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index 7a52236dfa..2d9cb51b95 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -488,6 +488,9 @@ 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. + * `get_project_directories`: Retrieves a list of project directories for a given LookML project. + * `create_project_directory`: Creates a new directory within a specified LookML project. + * `delete_project_directory`: Deletes a directory from a specified LookML project. * `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. diff --git a/docs/en/resources/tools/looker/looker-create-project-directory.md b/docs/en/resources/tools/looker/looker-create-project-directory.md new file mode 100644 index 0000000000..365d98a974 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-create-project-directory.md @@ -0,0 +1,44 @@ +--- +title: "looker-create-project-directory" +type: docs +weight: 1 +description: > + A "looker-create-project-directory" tool creates a new directory in a LookML project. +aliases: +- /resources/tools/looker-create-project-directory +--- + +## About + +A `looker-create-project-directory` tool creates a new directory within a specified LookML project. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +## Example + +```yaml +kind: tools +name: looker-create-project-directory +type: looker-create-project-directory +source: looker-source +description: | + This tool creates a new directory within a specific LookML project. + It is useful for organizing project files. + + Parameters: + - project_id (string): The ID of the LookML project. + - path (string): The path of the directory to create. + + Output: + A string confirming the creation of the directory. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-create-project-directory". | +| 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/docs/en/resources/tools/looker/looker-delete-project-directory.md b/docs/en/resources/tools/looker/looker-delete-project-directory.md new file mode 100644 index 0000000000..3bcf297734 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-delete-project-directory.md @@ -0,0 +1,44 @@ +--- +title: "looker-delete-project-directory" +type: docs +weight: 1 +description: > + A "looker-delete-project-directory" tool deletes a directory from a LookML project. +aliases: +- /resources/tools/looker-delete-project-directory +--- + +## About + +A `looker-delete-project-directory` tool deletes a directory from a specified LookML project. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +## Example + +```yaml +kind: tools +name: looker-delete-project-directory +type: looker-delete-project-directory +source: looker-source +description: | + This tool deletes a directory from a specific LookML project. + It is useful for removing unnecessary or obsolete directories. + + Parameters: + - project_id (string): The ID of the LookML project. + - path (string): The path of the directory to delete. + + Output: + A string confirming the deletion of the directory. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-delete-project-directory". | +| 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/docs/en/resources/tools/looker/looker-get-project-directories.md b/docs/en/resources/tools/looker/looker-get-project-directories.md new file mode 100644 index 0000000000..81cd0221ac --- /dev/null +++ b/docs/en/resources/tools/looker/looker-get-project-directories.md @@ -0,0 +1,43 @@ +--- +title: "looker-get-project-directories" +type: docs +weight: 1 +description: > + A "looker-get-project-directories" tool returns the directories within a specific LookML project. +aliases: +- /resources/tools/looker-get-project-directories +--- + +## About + +A `looker-get-project-directories` tool retrieves the directories within a specified LookML project. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +## Example + +```yaml +kind: tools +name: looker-get-project-directories +type: looker-get-project-directories +source: looker-source +description: | + This tool retrieves a list of directories within a specific LookML project. + It is useful for exploring the project structure. + + Parameters: + - project_id (string): The ID of the LookML project. + + Output: + A JSON array of strings, representing the directories within the project. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-get-project-directories". | +| 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 c6bbd51c56..2f3f62f670 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -959,6 +959,48 @@ tools: Output: A confirmation message upon successful file deletion. + get_project_directories: + kind: looker-get-project-directories + source: looker-source + description: | + This tool retrieves the list of directories within a specified LookML project. + + Parameters: + - project_id (required): The unique ID of the LookML project. + + Output: + A JSON array of strings, where each string is the name of a directory within the project. + + create_project_directory: + kind: looker-create-project-directory + source: looker-source + description: | + This tool creates a new directory within a specified LookML project. + + 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. + - directory_path (required): The path to the new directory within the project. + + Output: + A confirmation message upon successful directory creation. + + delete_project_directory: + kind: looker-delete-project-directory + source: looker-source + description: | + This tool permanently deletes a specified directory within a LookML project. + + 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. + - directory_path (required): The path to the directory within the project. + + Output: + A confirmation message upon successful directory deletion. + validate_project: kind: looker-validate-project source: looker-source @@ -1087,6 +1129,9 @@ toolsets: - create_project_file - update_project_file - delete_project_file + - get_project_directories + - create_project_directory + - delete_project_directory - validate_project - get_connections - get_connection_schemas diff --git a/internal/tools/looker/lookercommon/lookercommon.go b/internal/tools/looker/lookercommon/lookercommon.go index 2020feacd3..346b2060a1 100644 --- a/internal/tools/looker/lookercommon/lookercommon.go +++ b/internal/tools/looker/lookercommon/lookercommon.go @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -301,3 +301,34 @@ func UpdateProjectFile(l *v4.LookerSDK, projectId string, fileContent FileConten err := l.AuthSession.Do(nil, "PUT", "/4.0", path, nil, fileContent, options) return err } + +func GetProjectDirectories(l *v4.LookerSDK, projectId string, options *rtl.ApiSettings) (string, error) { + var result string + path := fmt.Sprintf("/projects/%s/directories", url.PathEscape(projectId)) + err := l.AuthSession.Do(&result, "GET", "/4.0", path, nil, nil, options) + return result, err +} + +type Directory struct { + Path string `json:"path"` +} + +func CreateProjectDirectory(l *v4.LookerSDK, projectId string, directoryPath string, options *rtl.ApiSettings) (string, error) { + d := Directory{ + Path: directoryPath, + } + var result string + path := fmt.Sprintf("/projects/%s/directories", url.PathEscape(projectId)) + err := l.AuthSession.Do(&result, "POST", "/4.0", path, nil, d, options) + return result, err +} + +func DeleteProjectDirectory(l *v4.LookerSDK, projectId string, directoryPath string, options *rtl.ApiSettings) (string, error) { + var query = map[string]any{ + "path": directoryPath, + } + var result string + path := fmt.Sprintf("/projects/%s/directories", url.PathEscape(projectId)) + err := l.AuthSession.Do(&result, "DELETE", "/4.0", path, query, nil, options) + return result, err +} diff --git a/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory.go b/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory.go new file mode 100644 index 0000000000..5c3b6e4624 --- /dev/null +++ b/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory.go @@ -0,0 +1,175 @@ +// Copyright 2026 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 lookercreateprojectdirectory + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "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/parameters" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const resourceType string = "looker-create-project-directory" + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 + LookerApiSettings() *rtl.ApiSettings + GetLookerSDK(string) (*v4.LookerSDK, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" 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) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project") + directoryPathParameter := parameters.NewStringParameter("directory_path", "The path to create in the project") + params := parameters.Parameters{projectIdParameter, directoryPathParameter} + + 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.Type) + if err != nil { + return nil, err + } + + sdk, err := source.GetLookerSDK(string(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"]) + } + directoryPath, ok := mapParams["directory_path"].(string) + if !ok { + return nil, fmt.Errorf("'directory_path' must be a string, got %T", mapParams["directory_path"]) + } + + _, err = lookercommon.CreateProjectDirectory(sdk, projectId, directoryPath, source.LookerApiSettings()) + if err != nil { + return nil, fmt.Errorf("error making create_project_directory request: %s", err) + } + + return fmt.Sprintf("Created directory %s in project %s", directoryPath, projectId), nil +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil) +} + +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.Type) + 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.Type) + if err != nil { + return "", err + } + return source.GetAuthTokenHeaderName(), nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory_test.go b/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory_test.go new file mode 100644 index 0000000000..df1937779a --- /dev/null +++ b/internal/tools/looker/lookercreateprojectdirectory/lookercreateprojectdirectory_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 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 lookercreateprojectdirectory_test + +import ( + "strings" + "testing" + + "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/lookercreateprojectdirectory" +) + +func TestParseFromYamlLookerCreateProjectDirectory(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: ` + kind: tools + name: example_tool + type: looker-create-project-directory + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Type: "looker-create-project-directory", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } + +} + +func TestFailParseFromYamlLookerCreateProjectDirectory(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: ` + kind: tools + name: example_tool + type: looker-create-project-directory + source: my-instance + method: GOT + description: some description + `, + err: "error unmarshaling tools: unable to parse tool \"example_tool\" as type \"looker-create-project-directory\": [3:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n> 3 | method: GOT\n ^\n 4 | name: example_tool\n 5 | source: my-instance\n 6 | type: looker-create-project-directory", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + 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/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory.go b/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory.go new file mode 100644 index 0000000000..c89e489155 --- /dev/null +++ b/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory.go @@ -0,0 +1,175 @@ +// Copyright 2026 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 lookerdeleteprojectdirectory + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "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/parameters" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const resourceType string = "looker-delete-project-directory" + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 + LookerApiSettings() *rtl.ApiSettings + GetLookerSDK(string) (*v4.LookerSDK, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" 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) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project") + directoryPathParameter := parameters.NewStringParameter("directory_path", "The path to delete in the project") + params := parameters.Parameters{projectIdParameter, directoryPathParameter} + + 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.Type) + if err != nil { + return nil, err + } + + sdk, err := source.GetLookerSDK(string(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"]) + } + directoryPath, ok := mapParams["directory_path"].(string) + if !ok { + return nil, fmt.Errorf("'directory_path' must be a string, got %T", mapParams["directory_path"]) + } + + _, err = lookercommon.DeleteProjectDirectory(sdk, projectId, directoryPath, source.LookerApiSettings()) + if err != nil { + return nil, fmt.Errorf("error making delete_project_directory request: %s", err) + } + + return fmt.Sprintf("Deleted directory %s in project %s", directoryPath, projectId), nil +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil) +} + +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.Type) + 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.Type) + if err != nil { + return "", err + } + return source.GetAuthTokenHeaderName(), nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory_test.go b/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory_test.go new file mode 100644 index 0000000000..c32cb6ef9e --- /dev/null +++ b/internal/tools/looker/lookerdeleteprojectdirectory/lookerdeleteprojectdirectory_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 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 lookerdeleteprojectdirectory_test + +import ( + "strings" + "testing" + + "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/lookerdeleteprojectdirectory" +) + +func TestParseFromYamlLookerDeleteProjectDirectory(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: ` + kind: tools + name: example_tool + type: looker-delete-project-directory + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Type: "looker-delete-project-directory", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } + +} + +func TestFailParseFromYamlLookerDeleteProjectDirectory(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: ` + kind: tools + name: example_tool + type: looker-delete-project-directory + source: my-instance + method: GOT + description: some description + `, + err: "error unmarshaling tools: unable to parse tool \"example_tool\" as type \"looker-delete-project-directory\": [3:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n> 3 | method: GOT\n ^\n 4 | name: example_tool\n 5 | source: my-instance\n 6 | type: looker-delete-project-directory", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + 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/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories.go b/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories.go new file mode 100644 index 0000000000..bd97a8402b --- /dev/null +++ b/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories.go @@ -0,0 +1,178 @@ +// Copyright 2026 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 lookergetprojectdirectories + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "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 resourceType string = "looker-get-project-directories" + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 + LookerApiSettings() *rtl.ApiSettings + GetLookerSDK(string) (*v4.LookerSDK, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" 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) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project") + 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.Type) + 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 := source.GetLookerSDK(string(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 := lookercommon.GetProjectDirectories(sdk, projectId, source.LookerApiSettings()) + if err != nil { + return nil, fmt.Errorf("error making get_project_directories request: %s", err) + } + + logger.DebugContext(ctx, "Got response of %v\n", resp) + + return resp, nil +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil) +} + +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.Type) + 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.Type) + if err != nil { + return "", err + } + return source.GetAuthTokenHeaderName(), nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories_test.go b/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories_test.go new file mode 100644 index 0000000000..6697d3c36c --- /dev/null +++ b/internal/tools/looker/lookergetprojectdirectories/lookergetprojectdirectories_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 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 lookergetprojectdirectories_test + +import ( + "strings" + "testing" + + "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/lookergetprojectdirectories" +) + +func TestParseFromYamlLookerGetProjectDirectories(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: ` + kind: tools + name: example_tool + type: looker-get-project-directories + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Type: "looker-get-project-directories", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } + +} + +func TestFailParseFromYamlLookerGetProjectDirectories(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: ` + kind: tools + name: example_tool + type: looker-get-project-directories + source: my-instance + method: GOT + description: some description + `, + err: "error unmarshaling tools: unable to parse tool \"example_tool\" as type \"looker-get-project-directories\": [3:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n> 3 | method: GOT\n ^\n 4 | name: example_tool\n 5 | source: my-instance\n 6 | type: looker-get-project-directories", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + 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 3aa795683e..46bf9924fa 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -222,6 +222,21 @@ func TestLooker(t *testing.T) { "source": "my-instance", "description": "Simple tool to test end to end functionality.", }, + "get_project_directories": map[string]any{ + "type": "looker-get-project-directories", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "create_project_directory": map[string]any{ + "type": "looker-create-project-directory", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "delete_project_directory": map[string]any{ + "type": "looker-delete-project-directory", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, "validate_project": map[string]any{ "type": "looker-validate-project", "source": "my-instance", @@ -1451,6 +1466,71 @@ func TestLooker(t *testing.T) { }, }, ) + tests.RunToolGetTestByName(t, "get_project_directories", + map[string]any{ + "get_project_directories": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{ + map[string]any{ + "authSources": []any{}, + "description": "The id of the project", + "name": "project_id", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "create_project_directory", + map[string]any{ + "create_project_directory": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{ + map[string]any{ + "authSources": []any{}, + "description": "The id of the project", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path to create in the project", + "name": "directory_path", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "delete_project_directory", + map[string]any{ + "delete_project_directory": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{ + map[string]any{ + "authSources": []any{}, + "description": "The id of the project", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path to delete in the project", + "name": "directory_path", + "required": true, + "type": "string", + }, + }, + }, + }, + ) tests.RunToolGetTestByName(t, "validate_project", map[string]any{ "validate_project": map[string]any{ @@ -1687,6 +1767,15 @@ func TestLooker(t *testing.T) { wantResult = "deleted" tests.RunToolInvokeParametersTest(t, "delete_project_file", []byte(`{"project_id": "the_look", "file_path": "foo.view.lkml"}`), wantResult) + wantResult = "created" + tests.RunToolInvokeParametersTest(t, "create_project_directory", []byte(`{"project_id": "the_look", "directory_path": "foo_dir"}`), wantResult) + + wantResult = "foo_dir" + tests.RunToolInvokeParametersTest(t, "get_project_directories", []byte(`{"project_id": "the_look"}`), wantResult) + + wantResult = "deleted" + tests.RunToolInvokeParametersTest(t, "delete_project_directory", []byte(`{"project_id": "the_look", "directory_path": "foo_dir"}`), wantResult) + wantResult = "\"errors\":[]" tests.RunToolInvokeParametersTest(t, "validate_project", []byte(`{"project_id": "the_look"}`), wantResult)