diff --git a/cmd/root.go b/cmd/root.go index 4f4a692507c..6f84eeff7d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -98,7 +98,10 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorevalidaterules" _ "github.com/googleapis/genai-toolbox/internal/tools/http" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardelement" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerconversationalanalytics" + _ "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/lookergetdashboards" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetdimensions" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetexplores" @@ -107,6 +110,9 @@ 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/lookergetprojectfile" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojectfiles" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetprojects" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthanalyze" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthpulse" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthvacuum" @@ -116,6 +122,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquerysql" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/mongodb/mongodbaggregate" _ "github.com/googleapis/genai-toolbox/internal/tools/mongodb/mongodbdeletemany" _ "github.com/googleapis/genai-toolbox/internal/tools/mongodb/mongodbdeleteone" diff --git a/cmd/root_test.go b/cmd/root_test.go index a8c36072c66..b8862592919 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1493,7 +1493,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "looker_tools": tools.ToolsetConfig{ Name: "looker_tools", - ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "make_dashboard", "add_dashboard_element", "health_pulse", "health_analyze", "health_vacuum"}, + 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"}, }, }, }, diff --git a/docs/en/resources/tools/looker/looker-create-project-file.md b/docs/en/resources/tools/looker/looker-create-project-file.md new file mode 100644 index 00000000000..89e56cd5093 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-create-project-file.md @@ -0,0 +1,45 @@ +--- +title: "looker-create-project-file" +type: docs +weight: 1 +description: > + A "looker-create-project-file" tool creates a new LookML file in a project. +aliases: +- /resources/tools/looker-create-project-file +--- + +## About + +A `looker-create-project-file` tool creates a new LookML file in a project + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-create-project-file` accepts a project_id parameter and a file_path parameter +as well as the file content. + +## Example + +```yaml +tools: + create_project_file: + kind: looker-create-project-file + source: looker-source + description: | + create_project_file Tool + + Given a project_id and a file path within the project, as well as the content + of a LookML file, this tool will create a new file within the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-create-project-file". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/docs/en/resources/tools/looker/looker-delete-project-file.md b/docs/en/resources/tools/looker/looker-delete-project-file.md new file mode 100644 index 00000000000..4371a81591d --- /dev/null +++ b/docs/en/resources/tools/looker/looker-delete-project-file.md @@ -0,0 +1,44 @@ +--- +title: "looker-delete-project-file" +type: docs +weight: 1 +description: > + A "looker-delete-project-file" tool deletes a LookML file in a project. +aliases: +- /resources/tools/looker-delete-project-file +--- + +## About + +A `looker-delete-project-file` tool deletes a LookML file in a project + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-delete-project-file` accepts a project_id parameter and a file_path parameter. + +## Example + +```yaml +tools: + delete_project_file: + kind: looker-delete-project-file + source: looker-source + description: | + delete_project_file Tool + + Given a project_id and a file path within the project, this tool will delete + the file from the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-delete-project-file". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/docs/en/resources/tools/looker/looker-dev-mode.md b/docs/en/resources/tools/looker/looker-dev-mode.md new file mode 100644 index 00000000000..89a6bb2a1f5 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-dev-mode.md @@ -0,0 +1,42 @@ +--- +title: "looker-dev-mode" +type: docs +weight: 1 +description: > + A "looker-dev-mode" tool changes the current session into and out of dev mode +aliases: +- /resources/tools/looker-dev-mode +--- + +## About + +A `looker-dev-mode` tool changes the session into and out of dev mode. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-dev-mode` accepts a boolean parameter, true to enter dev mode and false to exit dev mode. + + +## Example + +```yaml +tools: + dev_mode: + kind: looker-dev-mode + source: looker-source + description: | + dev_mode Tool + + Passing true to this tool switches the session to dev mode. Passing false to this tool switches the + session to production mode. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-dev-mode". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/docs/en/resources/tools/looker/looker-get-project-file.md b/docs/en/resources/tools/looker/looker-get-project-file.md new file mode 100644 index 00000000000..c90650ec572 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-get-project-file.md @@ -0,0 +1,41 @@ +--- +title: "looker-get-project-file" +type: docs +weight: 1 +description: > + A "looker-get-project-file" tool returns the contents of a LookML fle. +aliases: +- /resources/tools/looker-get-project-file +--- + +## About + +A `looker-get-project-file` tool returns the contents of a LookML file. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-get-project-file` accepts a project_id parameter and a file_path parameter. + +## Example + +```yaml +tools: + get_project_file: + kind: looker-get-project-file + source: looker-source + description: | + get_project_file Tool + + Given a project_id and a file path within the project, this tool returns + the contents of the LookML file. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-get-project-file". | +| 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-files.md b/docs/en/resources/tools/looker/looker-get-project-files.md new file mode 100644 index 00000000000..a3ac3dd850c --- /dev/null +++ b/docs/en/resources/tools/looker/looker-get-project-files.md @@ -0,0 +1,41 @@ +--- +title: "looker-get-project-files" +type: docs +weight: 1 +description: > + A "looker-get-project-files" tool returns all the LookML fles in a project in the source. +aliases: +- /resources/tools/looker-get-project-files +--- + +## About + +A `looker-get-project-files` tool returns all the lookml files in a project in the source. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-get-project-files` accepts a project_id parameter. + +## Example + +```yaml +tools: + get_project_files: + kind: looker-get-project-files + source: looker-source + description: | + get_project_files Tool + + Given a project_id this tool returns the details about + the LookML files that make up that project. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-get-project-files". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/docs/en/resources/tools/looker/looker-get-projects.md b/docs/en/resources/tools/looker/looker-get-projects.md new file mode 100644 index 00000000000..21cfc7039f2 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-get-projects.md @@ -0,0 +1,41 @@ +--- +title: "looker-get-projects" +type: docs +weight: 1 +description: > + A "looker-get-projects" tool returns all the LookML projects in the source. +aliases: +- /resources/tools/looker-get-projects +--- + +## About + +A `looker-get-projects` tool returns all the projects in the source. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-get-projects` accepts no parameters. + +## Example + +```yaml +tools: + get_projects: + kind: looker-get-projects + source: looker-source + description: | + get_projects Tool + + This tool returns the project_id and project_name for + all the LookML projects on the looker instance. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-get-projects". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/docs/en/resources/tools/looker/looker-update-project-file.md b/docs/en/resources/tools/looker/looker-update-project-file.md new file mode 100644 index 00000000000..604a7da3636 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-update-project-file.md @@ -0,0 +1,45 @@ +--- +title: "looker-update-project-file" +type: docs +weight: 1 +description: > + A "looker-update-project-file" tool updates the content of a LookML file in a project. +aliases: +- /resources/tools/looker-update-project-file +--- + +## About + +A `looker-update-project-file` tool updates the content of a LookML file. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-update-project-file` accepts a project_id parameter and a file_path parameter +as well as the new file content. + +## Example + +```yaml +tools: + update_project_file: + kind: looker-update-project-file + source: looker-source + description: | + update_project_file Tool + + Given a project_id and a file path within the project, as well as the content + of a LookML file, this tool will modify the file within the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------| +| kind | string | true | Must be "looker-update-project-file". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | \ No newline at end of file diff --git a/internal/prebuiltconfigs/tools/looker.yaml b/internal/prebuiltconfigs/tools/looker.yaml index b19aaef0022..73bc3b50ca7 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -745,6 +745,78 @@ tools: The result is a list of objects that are candidates for deletion. + dev_mode: + kind: looker-dev-mode + source: looker-source + description: | + dev_mode Tool + + Passing true to this tool switches the session to dev mode. Passing false to this tool switches the + session to production mode. + + get_projects: + kind: looker-get-projects + source: looker-source + description: | + get_projects Tool + + This tool returns the project_id and project_name for + all the LookML projects on the looker instance. + + get_project_files: + kind: looker-get-project-files + source: looker-source + description: | + get_project_files Tool + + Given a project_id this tool returns the details about + the LookML files that make up that project. + + get_project_file: + kind: looker-get-project-file + source: looker-source + description: | + get_project_file Tool + + Given a project_id and a file path within the project, this tool returns + the contents of the LookML file. + + create_project_file: + kind: looker-create-project-file + source: looker-source + description: | + create_project_file Tool + + Given a project_id and a file path within the project, as well as the content + of a LookML file, this tool will create a new file within the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. + + update_project_file: + kind: looker-update-project-file + source: looker-source + description: | + update_project_file Tool + + Given a project_id and a file path within the project, as well as the content + of a LookML file, this tool will modify the file within the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. + + delete_project_file: + kind: looker-delete-project-file + source: looker-source + description: | + delete_project_file Tool + + Given a project_id and a file path within the project, this tool will delete + the file from the project. + + This tool must be called after the dev_mode tool has changed the session to + dev mode. + toolsets: looker_tools: - get_models @@ -764,4 +836,11 @@ toolsets: - add_dashboard_element - health_pulse - health_analyze - - health_vacuum \ No newline at end of file + - health_vacuum + - dev_mode + - get_projects + - get_project_files + - get_project_file + - create_project_file + - update_project_file + - delete_project_file \ No newline at end of file diff --git a/internal/tools/looker/lookercommon/lookercommon.go b/internal/tools/looker/lookercommon/lookercommon.go index a122fd42502..16f0da5fedb 100644 --- a/internal/tools/looker/lookercommon/lookercommon.go +++ b/internal/tools/looker/lookercommon/lookercommon.go @@ -18,6 +18,7 @@ import ( "crypto/tls" "fmt" "net/http" + "net/url" "strings" "github.com/googleapis/genai-toolbox/internal/tools" @@ -310,3 +311,39 @@ func RunInlineQuery(ctx context.Context, sdk *v4.LookerSDK, wq *v4.WriteQuery, f } return resp, err } + +func GetProjectFileContent(l *v4.LookerSDK, projectId string, filePath string, options *rtl.ApiSettings) (string, error) { + var result string + path := fmt.Sprintf("/projects/%s/file/content", url.PathEscape(projectId)) + query := map[string]any{ + "file_path": url.QueryEscape(filePath), + } + err := l.AuthSession.Do(&result, "GET", "/4.0", path, query, nil, options) + return result, err +} + +func DeleteProjectFile(l *v4.LookerSDK, projectId string, filePath string, options *rtl.ApiSettings) error { + path := fmt.Sprintf("/projects/%s/files", url.PathEscape(projectId)) + query := map[string]any{ + "file_path": url.QueryEscape(filePath), + } + err := l.AuthSession.Do(nil, "DELETE", "/4.0", path, query, nil, options) + return err +} + +type FileContent struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func CreateProjectFile(l *v4.LookerSDK, projectId string, fileContent FileContent, options *rtl.ApiSettings) error { + path := fmt.Sprintf("/projects/%s/files", url.PathEscape(projectId)) + err := l.AuthSession.Do(nil, "POST", "/4.0", path, nil, fileContent, options) + return err +} + +func UpdateProjectFile(l *v4.LookerSDK, projectId string, fileContent FileContent, options *rtl.ApiSettings) error { + path := fmt.Sprintf("/projects/%s/files", url.PathEscape(projectId)) + err := l.AuthSession.Do(nil, "PUT", "/4.0", path, nil, fileContent, options) + return err +} diff --git a/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go new file mode 100644 index 00000000000..b182ff48f3d --- /dev/null +++ b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile.go @@ -0,0 +1,169 @@ +// 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 lookercreateprojectfile + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-create-project-file" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + projectIdParameter := tools.NewStringParameter("project_id", "The id of the project containing the files") + filePathParameter := tools.NewStringParameter("file_path", "The path of the file within the project") + fileContentParameter := tools.NewStringParameter("file_content", "The content of the file") + parameters := tools.Parameters{projectIdParameter, filePathParameter, fileContentParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, 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"]) + } + filePath, ok := mapParams["file_path"].(string) + if !ok { + return nil, fmt.Errorf("'file_path' must be a string, got %T", mapParams["file_path"]) + } + fileContent, ok := mapParams["file_content"].(string) + if !ok { + return nil, fmt.Errorf("'file_content' must be a string, got %T", mapParams["file_content"]) + } + + req := lookercommon.FileContent{ + Path: filePath, + Content: fileContent, + } + + err = lookercommon.CreateProjectFile(sdk, projectId, req, t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making create_project_file request: %s", err) + } + + data := make(map[string]any) + data["type"] = "text" + data["text"] = fmt.Sprintf("created file %s in project %s", filePath, projectId) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} \ No newline at end of file diff --git a/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile_test.go b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile_test.go new file mode 100644 index 00000000000..bd9b910b4df --- /dev/null +++ b/internal/tools/looker/lookercreateprojectfile/lookercreateprojectfile_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 lookercreateprojectfile_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/lookercreateprojectfile" +) + +func TestParseFromYamlLookerCreateProjectFile(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-create-project-file + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-create-project-file", + 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 TestFailParseFromYamlLookerCreateProjectFile(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-create-project-file + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-create-project-file\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-create-project-file\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go new file mode 100644 index 00000000000..4d20f043086 --- /dev/null +++ b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile.go @@ -0,0 +1,159 @@ +// 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 lookerdeleteprojectfile + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-delete-project-file" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + projectIdParameter := tools.NewStringParameter("project_id", "The id of the project containing the files") + filePathParameter := tools.NewStringParameter("file_path", "The path of the file within the project") + parameters := tools.Parameters{projectIdParameter, filePathParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, 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"]) + } + filePath, ok := mapParams["file_path"].(string) + if !ok { + return nil, fmt.Errorf("'file_path' must be a string, got %T", mapParams["file_path"]) + } + + err = lookercommon.DeleteProjectFile(sdk, projectId, filePath, t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making delete_project_file request: %s", err) + } + + data := make(map[string]any) + data["type"] = "text" + data["text"] = fmt.Sprintf("deleted file %s in project %s", filePath, projectId) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} \ No newline at end of file diff --git a/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile_test.go b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile_test.go new file mode 100644 index 00000000000..ff0ab2bf7b8 --- /dev/null +++ b/internal/tools/looker/lookerdeleteprojectfile/lookerdeleteprojectfile_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 lookerdeleteprojectfile_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/lookerdeleteprojectfile" +) + +func TestParseFromYamlLookerDeleteProjectFile(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-delete-project-file + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-delete-project-file", + 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 TestFailParseFromYamlLookerDeleteProjectFile(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-delete-project-file + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-delete-project-file\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-delete-project-file\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookerdevmode/lookerdevmode.go b/internal/tools/looker/lookerdevmode/lookerdevmode.go new file mode 100644 index 00000000000..973f71967f4 --- /dev/null +++ b/internal/tools/looker/lookerdevmode/lookerdevmode.go @@ -0,0 +1,166 @@ +// 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 lookerdevmode + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + "github.com/googleapis/genai-toolbox/internal/util" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-dev-mode" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + devModeParameter := tools.NewBooleanParameterWithDefault("devMode", true, "Whether to set Dev Mode.") + parameters := tools.Parameters{devModeParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + ShowHiddenExplores: s.ShowHiddenExplores, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest + ShowHiddenExplores bool +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + mapParams := params.AsMap() + devMode, ok := mapParams["devMode"].(bool) + if !ok { + return nil, fmt.Errorf("'devMode' must be a boolean, got %T", mapParams["devMode"]) + } + + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken) + if err != nil { + return nil, fmt.Errorf("error getting sdk: %w", err) + } + var devModeString string + if devMode { + devModeString = "dev" + } else { + devModeString = "production" + } + req := v4.WriteApiSession{ + WorkspaceId: &devModeString, + } + resp, err := sdk.UpdateSession(req, t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error setting/resetting dev mode: %w", err) + } + logger.DebugContext(ctx, "result = ", resp) + + return resp, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} \ No newline at end of file diff --git a/internal/tools/looker/lookerdevmode/lookerdevmode_test.go b/internal/tools/looker/lookerdevmode/lookerdevmode_test.go new file mode 100644 index 00000000000..dd776f77e20 --- /dev/null +++ b/internal/tools/looker/lookerdevmode/lookerdevmode_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 lookerdevmode_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/lookerdevmode" +) + +func TestParseFromYamlLookerDevMode(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-dev-mode + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-dev-mode", + 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 TestFailParseFromYamlLookerDevMode(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-dev-mode + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-dev-mode\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-dev-mode\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookergetprojectfile/lookergetprojectfile.go b/internal/tools/looker/lookergetprojectfile/lookergetprojectfile.go new file mode 100644 index 00000000000..c3110a58c3a --- /dev/null +++ b/internal/tools/looker/lookergetprojectfile/lookergetprojectfile.go @@ -0,0 +1,166 @@ +// 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 lookergetprojectfile + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + "github.com/googleapis/genai-toolbox/internal/util" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-get-project-file" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + projectIdParameter := tools.NewStringParameter("project_id", "The id of the project containing the files") + filePathParameter := tools.NewStringParameter("file_path", "The path of the file within the project") + parameters := tools.Parameters{projectIdParameter, filePathParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, 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"]) + } + filePath, ok := mapParams["file_path"].(string) + if !ok { + return nil, fmt.Errorf("'file_path' must be a string, got %T", mapParams["file_path"]) + } + + resp, err := lookercommon.GetProjectFileContent(sdk, projectId, filePath, t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making get_project_file request: %s", err) + } + + logger.DebugContext(ctx, "Got response of %v\n", resp) + data := make(map[string]any) + data["type"] = "text" + data["text"] = resp + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/looker/lookergetprojectfile/lookergetprojectfile_test.go b/internal/tools/looker/lookergetprojectfile/lookergetprojectfile_test.go new file mode 100644 index 00000000000..cfd182c1d6c --- /dev/null +++ b/internal/tools/looker/lookergetprojectfile/lookergetprojectfile_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 lookergetprojectfile_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/lookergetprojectfile" +) + +func TestParseFromYamlLookerGetProjectFile(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-get-project-file + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-get-project-file", + 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 TestFailParseFromYamlLookerGetProjectFile(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-get-project-file + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-get-project-file\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-get-project-file\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles.go b/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles.go new file mode 100644 index 00000000000..0af99995076 --- /dev/null +++ b/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles.go @@ -0,0 +1,183 @@ +// 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 lookergetprojectfiles + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + "github.com/googleapis/genai-toolbox/internal/util" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-get-project-files" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + projectIdParameter := tools.NewStringParameter("project_id", "The id of the project containing the files") + parameters := tools.Parameters{projectIdParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, 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.AllProjectFiles(projectId, "", t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making get_project_files request: %s", err) + } + + var data []any + for _, v := range resp { + logger.DebugContext(ctx, "Got response element of %v\n", v) + vMap := make(map[string]any) + if v.Id != nil { + vMap["id"] = *v.Id + } + if v.Path != nil { + vMap["path"] = *v.Path + } + if v.Title != nil { + vMap["title"] = *v.Title + } + if v.Type != nil { + vMap["type"] = *v.Type + } + if v.Extension != nil { + vMap["extension"] = *v.Extension + } + if v.Editable != nil { + vMap["editable"] = *v.Editable + } + logger.DebugContext(ctx, "Converted to %v\n", vMap) + data = append(data, vMap) + } + logger.DebugContext(ctx, "data = ", data) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles_test.go b/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles_test.go new file mode 100644 index 00000000000..83a315b449e --- /dev/null +++ b/internal/tools/looker/lookergetprojectfiles/lookergetprojectfiles_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 lookergetprojectfiles_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/lookergetprojectfiles" +) + +func TestParseFromYamlLookerGetProjectFiles(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-get-project-files + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-get-project-files", + 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 TestFailParseFromYamlLookerGetProjectFiles(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-get-project-files + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-get-project-files\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-get-project-files\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookergetprojects/lookergetprojects.go b/internal/tools/looker/lookergetprojects/lookergetprojects.go new file mode 100644 index 00000000000..4f803b8524a --- /dev/null +++ b/internal/tools/looker/lookergetprojects/lookergetprojects.go @@ -0,0 +1,160 @@ +// 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 lookergetprojects + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + "github.com/googleapis/genai-toolbox/internal/util" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-get-projects" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + parameters := tools.Parameters{} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + UseClientOAuth: s.UseClientOAuth, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken) + if err != nil { + return nil, fmt.Errorf("error getting sdk: %w", err) + } + + resp, err := sdk.AllProjects("id,name", t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making get_models request: %s", err) + } + + var data []any + for _, v := range resp { + logger.DebugContext(ctx, "Got response element of %v\n", v) + vMap := make(map[string]any) + vMap["project_id"] = *v.Id + vMap["project_name"] = *v.Name + logger.DebugContext(ctx, "Converted to %v\n", vMap) + data = append(data, vMap) + } + logger.DebugContext(ctx, "data = ", data) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParamValues{}, nil +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} diff --git a/internal/tools/looker/lookergetprojects/lookergetprojects_test.go b/internal/tools/looker/lookergetprojects/lookergetprojects_test.go new file mode 100644 index 00000000000..3a25be54d52 --- /dev/null +++ b/internal/tools/looker/lookergetprojects/lookergetprojects_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 lookergetprojects_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/lookergetprojects" +) + +func TestParseFromYamlLookerGetProjects(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-get-projects + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-get-projects", + 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 TestFailParseFromYamlLookerGetProjecProjects(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-get-projects + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-get-projects\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-get-projects\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) + } + }) + } + +} diff --git a/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go new file mode 100644 index 00000000000..32deff567c3 --- /dev/null +++ b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile.go @@ -0,0 +1,169 @@ +// 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 lookerupdateprojectfile + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const kind string = "looker-update-project-file" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(*lookersrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) + } + + projectIdParameter := tools.NewStringParameter("project_id", "The id of the project containing the files") + filePathParameter := tools.NewStringParameter("file_path", "The path of the file within the project") + fileContentParameter := tools.NewStringParameter("file_content", "The content of the file") + parameters := tools.Parameters{projectIdParameter, filePathParameter, fileContentParameter} + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) + + // finish tool setup + return Tool{ + Name: cfg.Name, + Kind: kind, + Parameters: parameters, + AuthRequired: cfg.AuthRequired, + UseClientOAuth: s.UseClientOAuth, + Client: s.Client, + ApiSettings: s.ApiSettings, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: parameters.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + UseClientOAuth bool + Client *v4.LookerSDK + ApiSettings *rtl.ApiSettings + AuthRequired []string `yaml:"authRequired"` + Parameters tools.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, 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"]) + } + filePath, ok := mapParams["file_path"].(string) + if !ok { + return nil, fmt.Errorf("'file_path' must be a string, got %T", mapParams["file_path"]) + } + fileContent, ok := mapParams["file_content"].(string) + if !ok { + return nil, fmt.Errorf("'file_content' must be a string, got %T", mapParams["file_content"]) + } + + req := lookercommon.FileContent{ + Path: filePath, + Content: fileContent, + } + + err = lookercommon.UpdateProjectFile(sdk, projectId, req, t.ApiSettings) + if err != nil { + return nil, fmt.Errorf("error making update_project_file request: %s", err) + } + + data := make(map[string]any) + data["type"] = "text" + data["text"] = fmt.Sprintf("updated file %s in project %s", filePath, projectId) + + return data, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.Parameters, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.UseClientOAuth +} \ No newline at end of file diff --git a/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile_test.go b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile_test.go new file mode 100644 index 00000000000..fef23faab72 --- /dev/null +++ b/internal/tools/looker/lookerupdateprojectfile/lookerupdateprojectfile_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 lookerupdateprojectfile_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/lookerupdateprojectfile" +) + +func TestParseFromYamlLookerUpdateProjectFile(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-update-project-file + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Kind: "looker-update-project-file", + 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 TestFailParseFromYamlLookerUpdateProjectFile(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-update-project-file + source: my-instance + method: GOT + description: some description + `, + err: "unable to parse tool \"example_tool\" as kind \"looker-update-project-file\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-update-project-file\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) + } + }) + } + +} diff --git a/tests/looker/looker_integration_test.go b/tests/looker/looker_integration_test.go index 9d87adc8053..d855872444a 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -164,6 +164,41 @@ func TestLooker(t *testing.T) { "source": "my-instance", "description": "Vacuums unused content from a Looker instance.", }, + "dev_mode": map[string]any{ + "kind": "looker-dev-mode", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "get_projects": map[string]any{ + "kind": "looker-get-projects", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "get_project_files": map[string]any{ + "kind": "looker-get-project-files", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "get_project_file": map[string]any{ + "kind": "looker-get-project-file", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "create_project_file": map[string]any{ + "kind": "looker-create-project-file", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "update_project_file": map[string]any{ + "kind": "looker-update-project-file", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "delete_project_file": map[string]any{ + "kind": "looker-delete-project-file", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, }, } @@ -651,7 +686,6 @@ func TestLooker(t *testing.T) { }, }, ) - tests.RunToolGetTestByName(t, "conversational_analytics", map[string]any{ "conversational_analytics": map[string]any{ @@ -805,6 +839,159 @@ func TestLooker(t *testing.T) { }, }, ) + tests.RunToolGetTestByName(t, "dev_mode", + map[string]any{ + "dev_mode": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{ + map[string]any{ + "authSources": []any{}, + "description": "Whether to set Dev Mode.", + "name": "devMode", + "required": false, + "type": "boolean", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "get_projects", + map[string]any{ + "get_projects": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{}, + }, + }, + ) + tests.RunToolGetTestByName(t, "get_project_files", + map[string]any{ + "get_project_files": 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 containing the files", + "name": "project_id", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "get_project_file", + map[string]any{ + "get_project_file": 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 containing the files", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path of the file within the project", + "name": "file_path", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "create_project_file", + map[string]any{ + "create_project_file": 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 containing the files", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path of the file within the project", + "name": "file_path", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The content of the file", + "name": "file_content", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "update_project_file", + map[string]any{ + "update_project_file": 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 containing the files", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path of the file within the project", + "name": "file_path", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The content of the file", + "name": "file_content", + "required": true, + "type": "string", + }, + }, + }, + }, + ) + tests.RunToolGetTestByName(t, "delete_project_file", + map[string]any{ + "delete_project_file": 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 containing the files", + "name": "project_id", + "required": true, + "type": "string", + }, + map[string]any{ + "authSources": []any{}, + "description": "The path of the file within the project", + "name": "file_path", + "required": true, + "type": "string", + }, + }, + }, + }, + ) wantResult := "{\"label\":\"System Activity\",\"name\":\"system__activity\",\"project_name\":\"system__activity\"}" tests.RunToolInvokeSimpleTest(t, "get_models", wantResult) @@ -859,6 +1046,30 @@ func TestLooker(t *testing.T) { wantResult = "\"Model\":\"the_look\"" tests.RunToolInvokeParametersTest(t, "health_vacuum", []byte(`{"action": "models"}`), wantResult) + + wantResult = "the_look" + tests.RunToolInvokeSimpleTest(t, "get_projects", wantResult) + + wantResult = "order_items.view" + tests.RunToolInvokeParametersTest(t, "get_project_files", []byte(`{"project_id": "the_look"}`), wantResult) + + wantResult = "view" + tests.RunToolInvokeParametersTest(t, "get_project_file", []byte(`{"project_id": "the_look", "file_path": "order_items.view.lkml"}`), wantResult) + + wantResult = "dev" + tests.RunToolInvokeParametersTest(t, "dev_mode", []byte(`{"devMode": true}`), wantResult) + + wantResult = "created" + tests.RunToolInvokeParametersTest(t, "create_project_file", []byte(`{"project_id": "the_look", "file_path": "foo.view.lkml", "file_content": "view"}`), wantResult) + + wantResult = "updated" + tests.RunToolInvokeParametersTest(t, "update_project_file", []byte(`{"project_id": "the_look", "file_path": "foo.view.lkml", "file_content": "model"}`), wantResult) + + wantResult = "deleted" + tests.RunToolInvokeParametersTest(t, "delete_project_file", []byte(`{"project_id": "the_look", "file_path": "foo.view.lkml"}`), wantResult) + + wantResult = "production" + tests.RunToolInvokeParametersTest(t, "dev_mode", []byte(`{"devMode": false}`), wantResult) } func runConversationalAnalytics(t *testing.T, modelName, exploreName string) {