From e6a6c615d5480e8930ad173d44d243f5bd99eebc Mon Sep 17 00:00:00 2001 From: prernakakkar-google <158031829+prernakakkar-google@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:55:28 +0000 Subject: [PATCH] feat(prebuilt/cloudsql): Add list databases tool for cloud sql (#1454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description --- feat: Add tool to list Cloud SQL databases This change introduces a new tool for listing databases within a Google Cloud SQL instance. The new tool, `list-databases`, is part of the `cloud-sql-admin` source and allows users to retrieve a list of all databases for a given instance. ### Detailed Description The `list-databases` tool provides a simple and direct way to inspect the databases present in a Cloud SQL instance. It is implemented in `internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases.go`. **Key Features:** * **Tool Name:** `list-databases` * **Source:** `cloud-sql-admin` * **Parameters:** * `project` (required): The Google Cloud project ID. * `instance` (required): The ID of the Cloud SQL instance. * **Functionality:** * The tool uses the `sqladmin.Databases.List` API to fetch the list of databases. * It formats the output into a JSON array, where each object contains the `name`, `charset`, and `collation` of a database. * If no databases are found, it returns an empty array. ## PR Checklist --- > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [x] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [x] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes # --- cmd/root.go | 1 + .../tools/cloudsql/cloudsqllistdatabases.md | 47 ++++ .../cloudsqllistdatabases.go | 182 +++++++++++++++ .../cloudsqllistdatabases_test.go | 72 ++++++ .../cloudsql/cloud_sql_list_databases_test.go | 213 ++++++++++++++++++ 5 files changed, 515 insertions(+) create mode 100644 docs/en/resources/tools/cloudsql/cloudsqllistdatabases.md create mode 100644 internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases.go create mode 100644 internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases_test.go create mode 100644 tests/cloudsql/cloud_sql_list_databases_test.go diff --git a/cmd/root.go b/cmd/root.go index 52ab87f6a5..dd22625a53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -66,6 +66,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreatedatabase" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreateusers" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlgetinstances" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqllistdatabases" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqllistinstances" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlwaitforoperation" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsqlmssql/cloudsqlmssqlcreateinstance" diff --git a/docs/en/resources/tools/cloudsql/cloudsqllistdatabases.md b/docs/en/resources/tools/cloudsql/cloudsqllistdatabases.md new file mode 100644 index 0000000000..54f8f3401d --- /dev/null +++ b/docs/en/resources/tools/cloudsql/cloudsqllistdatabases.md @@ -0,0 +1,47 @@ +--- +title: cloud-sql-list-databases +type: docs +weight: 1 +description: List Cloud SQL databases in an instance. +--- + +The `cloud-sql-list-databases` tool lists all Cloud SQL databases in a specified +Google Cloud project and instance. + +{{< notice info >}} +This tool uses the `cloud-sql-admin` source. +{{< /notice >}} + +## Configuration + +Here is an example of how to configure the `cloud-sql-list-databases` tool in your +`tools.yaml` file: + +```yaml +sources: + my-cloud-sql-admin-source: + kind: cloud-sql-admin + +tools: + list_my_databases: + kind: cloud-sql-list-databases + source: my-cloud-sql-admin-source + description: Use this tool to list all Cloud SQL databases in an instance. +``` + +## Parameters + +The `cloud-sql-list-databases` tool has two required parameters: + +| **field** | **type** | **required** | **description** | +| --------- | :------: | :----------: | ---------------------------- | +| project | string | true | The Google Cloud project ID. | +| instance | string | true | The Cloud SQL instance ID. | + +## Reference + +| **field** | **type** | **required** | **description** | +| ----------- | :------: | :----------: | -------------------------------------------------------------- | +| kind | string | true | Must be "cloud-sql-list-databases". | +| source | string | true | The name of the `cloud-sql-admin` source to use for this tool. | +| description | string | false | Description of the tool that is passed to the agent. | diff --git a/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases.go b/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases.go new file mode 100644 index 0000000000..eef0cc9d01 --- /dev/null +++ b/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases.go @@ -0,0 +1,182 @@ +// 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 cloudsqllistdatabases + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + cloudsqladminsrc "github.com/googleapis/genai-toolbox/internal/sources/cloudsqladmin" + "github.com/googleapis/genai-toolbox/internal/tools" +) + +const kind string = "cloud-sql-list-databases" + +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 +} + +// Config defines the configuration for the list-databases tool. +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"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +// ToolConfigKind returns the kind of the tool. +func (cfg Config) ToolConfigKind() string { + return kind +} + +// Initialize initializes the tool from the configuration. +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + s, ok := rawS.(*cloudsqladminsrc.Source) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be `cloud-sql-admin`", kind) + } + + allParameters := tools.Parameters{ + tools.NewStringParameter("project", "The project ID"), + tools.NewStringParameter("instance", "The instance ID"), + } + paramManifest := allParameters.Manifest() + + inputSchema := allParameters.McpManifest() + inputSchema.Required = []string{"project", "instance"} + + description := cfg.Description + if description == "" { + description = "Lists all databases for a Cloud SQL instance." + } + + mcpManifest := tools.McpManifest{ + Name: cfg.Name, + Description: description, + InputSchema: inputSchema, + } + + return Tool{ + Name: cfg.Name, + Kind: kind, + AuthRequired: cfg.AuthRequired, + Source: s, + AllParams: allParameters, + manifest: tools.Manifest{Description: description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + }, nil +} + +// Tool represents the list-databases tool. +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Description string `yaml:"description"` + AuthRequired []string `yaml:"authRequired"` + + AllParams tools.Parameters `yaml:"allParams"` + Source *cloudsqladminsrc.Source + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +// Invoke executes the tool's logic. +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + paramsMap := params.AsMap() + + project, ok := paramsMap["project"].(string) + if !ok { + return nil, fmt.Errorf("missing 'project' parameter") + } + instance, ok := paramsMap["instance"].(string) + if !ok { + return nil, fmt.Errorf("missing 'instance' parameter") + } + + service, err := t.Source.GetService(ctx, string(accessToken)) + if err != nil { + return nil, err + } + + resp, err := service.Databases.List(project, instance).Do() + if err != nil { + return nil, fmt.Errorf("error listing databases: %w", err) + } + + if resp.Items == nil { + return []any{}, nil + } + + type databaseInfo struct { + Name string `json:"name"` + Charset string `json:"charset"` + Collation string `json:"collation"` + } + + var databases []databaseInfo + for _, item := range resp.Items { + databases = append(databases, databaseInfo{ + Name: item.Name, + Charset: item.Charset, + Collation: item.Collation, + }) + } + + return databases, nil +} + +// ParseParams parses the parameters for the tool. +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.AllParams, data, claims) +} + +// Manifest returns the tool's manifest. +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +// McpManifest returns the tool's MCP manifest. +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +// Authorized checks if the tool is authorized. +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return true +} + +func (t Tool) RequiresClientAuthorization() bool { + return t.Source.UseClientAuthorization() +} diff --git a/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases_test.go b/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases_test.go new file mode 100644 index 0000000000..f7c1d49380 --- /dev/null +++ b/internal/tools/cloudsql/cloudsqllistdatabases/cloudsqllistdatabases_test.go @@ -0,0 +1,72 @@ +// 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 cloudsqllistdatabases_test + +import ( + "testing" + + "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" + "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqllistdatabases" +) + +func TestParseFromYaml(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: + list-my-databases: + kind: cloud-sql-list-databases + description: some description + source: some-source + `, + want: server.ToolConfigs{ + "list-my-databases": cloudsqllistdatabases.Config{ + Name: "list-my-databases", + Kind: "cloud-sql-list-databases", + Description: "some description", + AuthRequired: []string{}, + Source: "some-source", + }, + }, + }, + } + 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) + } + }) + } +} diff --git a/tests/cloudsql/cloud_sql_list_databases_test.go b/tests/cloudsql/cloud_sql_list_databases_test.go new file mode 100644 index 0000000000..6ee0f9b9eb --- /dev/null +++ b/tests/cloudsql/cloud_sql_list_databases_test.go @@ -0,0 +1,213 @@ +// 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 cloudsql + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "regexp" + "strings" + "testing" + "time" + + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/tests" +) + +var ( + listDatabasesToolKind = "cloud-sql-list-databases" +) + +type listDatabasesTransport struct { + transport http.RoundTripper + url *url.URL +} + +func (t *listDatabasesTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.HasPrefix(req.URL.String(), "https://sqladmin.googleapis.com") { + req.URL.Scheme = t.url.Scheme + req.URL.Host = t.url.Host + } + return t.transport.RoundTrip(req) +} + +type masterListDatabasesHandler struct { + t *testing.T +} + +func (h *masterListDatabasesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.UserAgent(), "genai-toolbox/") { + h.t.Errorf("User-Agent header not found") + } + + response := map[string]any{ + "items": []map[string]any{ + { + "name": "db1", + "charset": "utf8", + "collation": "utf8_general_ci", + }, + { + "name": "db2", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + }, + }, + } + statusCode := http.StatusOK + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func TestListDatabasesToolEndpoints(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + handler := &masterListDatabasesHandler{t: t} + server := httptest.NewServer(handler) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse server URL: %v", err) + } + + originalTransport := http.DefaultClient.Transport + if originalTransport == nil { + originalTransport = http.DefaultTransport + } + http.DefaultClient.Transport = &listDatabasesTransport{ + transport: originalTransport, + url: serverURL, + } + t.Cleanup(func() { + http.DefaultClient.Transport = originalTransport + }) + + var args []string + toolsFile := getListDatabasesToolsConfig() + cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out) + if err != nil { + t.Logf("toolbox command logs: \n%s", out) + t.Fatalf("toolbox didn't start successfully: %s", err) + } + + tcs := []struct { + name string + toolName string + body string + want string + expectError bool + errorStatus int + }{ + { + name: "successful databases listing", + toolName: "list-databases", + body: `{"project": "p1", "instance": "i1"}`, + want: `[{"name":"db1","charset":"utf8","collation":"utf8_general_ci"},{"name":"db2","charset":"utf8mb4","collation":"utf8mb4_unicode_ci"}]`, + }, + { + name: "missing instance", + toolName: "list-databases", + body: `{"project": "p1"}`, + expectError: true, + errorStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + api := fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", tc.toolName) + req, err := http.NewRequest(http.MethodPost, api, bytes.NewBufferString(tc.body)) + if err != nil { + t.Fatalf("unable to create request: %s", err) + } + req.Header.Add("Content-type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("unable to send request: %s", err) + } + defer resp.Body.Close() + + if tc.expectError { + if resp.StatusCode != tc.errorStatus { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status %d but got %d: %s", tc.errorStatus, resp.StatusCode, string(bodyBytes)) + } + return + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + Result string `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + var got, want []map[string]any + if err := json.Unmarshal([]byte(result.Result), &got); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if err := json.Unmarshal([]byte(tc.want), &want); err != nil { + t.Fatalf("failed to unmarshal want: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected result: got %+v, want %+v", got, want) + } + }) + } +} + +func getListDatabasesToolsConfig() map[string]any { + return map[string]any{ + "sources": map[string]any{ + "my-cloud-sql-source": map[string]any{ + "kind": "cloud-sql-admin", + }, + }, + "tools": map[string]any{ + "list-databases": map[string]any{ + "kind": listDatabasesToolKind, + "source": "my-cloud-sql-source", + }, + }, + } +}