diff --git a/.hugo/hugo.toml b/.hugo/hugo.toml index 685f6624abca..e3b996b7cafb 100644 --- a/.hugo/hugo.toml +++ b/.hugo/hugo.toml @@ -51,6 +51,10 @@ ignoreFiles = ["quickstart/shared", "quickstart/python", "quickstart/js", "quick # Add a new version block here before every release # The order of versions in this file is mirrored into the dropdown +[[params.versions]] + version = "v0.23.0" + url = "https://googleapis.github.io/genai-toolbox/v0.23.0/" + [[params.versions]] version = "v0.22.0" url = "https://googleapis.github.io/genai-toolbox/v0.22.0/" diff --git a/README.md b/README.md index 4d2369da787c..4ee0eec35e34 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,21 @@ redeploying your application. ## Getting Started +### (Non-production) Running Toolbox + +You can run Toolbox directly with a [configuration file](#configuration): + +```sh +npx @toolbox-sdk/server --tools-file tools.yaml +``` + +This runs the latest version of the toolbox server with your configuration file. + +> [!NOTE] +> This method should only be used for non-production use cases such as +> experimentation. For any production use-cases, please consider [Installing the +> server](#installing-the-server) and then [running it](#running-the-server). + ### Installing the server For the latest version, check the [releases page][releases] and use the @@ -303,6 +318,16 @@ toolbox --tools-file "tools.yaml" +
+NPM + +To run Toolbox directly without manually downloading the binary (requires Node.js): +```sh +npx @toolbox-sdk/server --tools-file tools.yaml +``` + +
+
Gemini CLI diff --git a/docs/en/getting-started/introduction/_index.md b/docs/en/getting-started/introduction/_index.md index b346038c4ec4..ddc6bdf45504 100644 --- a/docs/en/getting-started/introduction/_index.md +++ b/docs/en/getting-started/introduction/_index.md @@ -71,6 +71,22 @@ redeploying your application. ## Getting Started +### (Non-production) Running Toolbox + +You can run Toolbox directly with a [configuration file](../configure.md): + +```sh +npx @toolbox-sdk/server --tools-file tools.yaml +``` + +This runs the latest version of the toolbox server with your configuration file. + +{{< notice note >}} +This method should only be used for non-production use cases such as +experimentation. For any production use-cases, please consider [Installing the +server](#installing-the-server) and then [running it](#running-the-server). +{{< /notice >}} + ### Installing the server For the latest version, check the [releases page][releases] and use the diff --git a/docs/en/getting-started/prompts_quickstart_gemini_cli.md b/docs/en/getting-started/prompts_quickstart_gemini_cli.md new file mode 100644 index 000000000000..2061acd7fae9 --- /dev/null +++ b/docs/en/getting-started/prompts_quickstart_gemini_cli.md @@ -0,0 +1,245 @@ +--- +title: "Prompts using Gemini CLI" +type: docs +weight: 5 +description: > + How to get started using Toolbox prompts locally with PostgreSQL and [Gemini CLI](https://pypi.org/project/gemini-cli/). +--- + +## Before you begin + +This guide assumes you have already done the following: + +1. Installed [PostgreSQL 16+ and the `psql` client][install-postgres]. + +[install-postgres]: https://www.postgresql.org/download/ + +## Step 1: Set up your database + +In this section, we will create a database, insert some data that needs to be +accessed by our agent, and create a database user for Toolbox to connect with. + +1. Connect to postgres using the `psql` command: + + ```bash + psql -h 127.0.0.1 -U postgres + ``` + + Here, `postgres` denotes the default postgres superuser. + + {{< notice info >}} + +#### **Having trouble connecting?** + +* **Password Prompt:** If you are prompted for a password for the `postgres` + user and do not know it (or a blank password doesn't work), your PostgreSQL + installation might require a password or a different authentication method. +* **`FATAL: role "postgres" does not exist`:** This error means the default + `postgres` superuser role isn't available under that name on your system. +* **`Connection refused`:** Ensure your PostgreSQL server is actually running. + You can typically check with `sudo systemctl status postgresql` and start it + with `sudo systemctl start postgresql` on Linux systems. + +
+ +#### **Common Solution** + +For password issues or if the `postgres` role seems inaccessible directly, try +switching to the `postgres` operating system user first. This user often has +permission to connect without a password for local connections (this is called +peer authentication). + +```bash +sudo -i -u postgres +psql -h 127.0.0.1 +``` + +Once you are in the `psql` shell using this method, you can proceed with the +database creation steps below. Afterwards, type `\q` to exit `psql`, and then +`exit` to return to your normal user shell. + +If desired, once connected to `psql` as the `postgres` OS user, you can set a +password for the `postgres` *database* user using: `ALTER USER postgres WITH +PASSWORD 'your_chosen_password';`. This would allow direct connection with `-U +postgres` and a password next time. + {{< /notice >}} + +1. Create a new database and a new user: + + {{< notice tip >}} + For a real application, it's best to follow the principle of least permission + and only grant the privileges your application needs. + {{< /notice >}} + + ```sql + CREATE USER toolbox_user WITH PASSWORD 'my-password'; + + CREATE DATABASE toolbox_db; + GRANT ALL PRIVILEGES ON DATABASE toolbox_db TO toolbox_user; + + ALTER DATABASE toolbox_db OWNER TO toolbox_user; + ``` + +1. End the database session: + + ```bash + \q + ``` + + (If you used `sudo -i -u postgres` and then `psql`, remember you might also + need to type `exit` after `\q` to leave the `postgres` user's shell + session.) + +1. Connect to your database with your new user: + + ```bash + psql -h 127.0.0.1 -U toolbox_user -d toolbox_db + ``` + +1. Create the required tables using the following commands: + + ```sql + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE restaurants ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + location VARCHAR(100) + ); + + CREATE TABLE reviews ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id), + restaurant_id INT REFERENCES restaurants(id), + rating INT CHECK (rating >= 1 AND rating <= 5), + review_text TEXT, + is_published BOOLEAN DEFAULT false, + moderation_status VARCHAR(50) DEFAULT 'pending_manual_review', + created_at TIMESTAMPTZ DEFAULT NOW() + ); + ``` + +1. Insert dummy data into the tables. + + ```sql + INSERT INTO users (id, username, email) VALUES + (123, 'jane_d', 'jane.d@example.com'), + (124, 'john_s', 'john.s@example.com'), + (125, 'sam_b', 'sam.b@example.com'); + + INSERT INTO restaurants (id, name, location) VALUES + (455, 'Pizza Palace', '123 Main St'), + (456, 'The Corner Bistro', '456 Oak Ave'), + (457, 'Sushi Spot', '789 Pine Ln'); + + INSERT INTO reviews (user_id, restaurant_id, rating, review_text, is_published, moderation_status) VALUES + (124, 455, 5, 'Best pizza in town! The crust was perfect.', true, 'approved'), + (125, 457, 4, 'Great sushi, very fresh. A bit pricey but worth it.', true, 'approved'), + (123, 457, 5, 'Absolutely loved the dragon roll. Will be back!', true, 'approved'), + (123, 456, 4, 'The atmosphere was lovely and the food was great. My photo upload might have been weird though.', false, 'pending_manual_review'), + (125, 456, 1, 'This review contains inappropriate language.', false, 'rejected'); + ``` + +1. End the database session: + + ```bash + \q + ``` + +## Step 2: Configure Toolbox + +Create a file named `tools.yaml`. This file defines the database connection, the +SQL tools available, and the prompts the agents will use. + +```yaml +sources: + my-foodiefind-db: + kind: postgres + host: 127.0.0.1 + port: 5432 + database: toolbox_db + user: toolbox_user + password: my-password +tools: + find_user_by_email: + kind: postgres-sql + source: my-foodiefind-db + description: Find a user's ID by their email address. + parameters: + - name: email + type: string + description: The email address of the user to find. + statement: SELECT id FROM users WHERE email = $1; + find_restaurant_by_name: + kind: postgres-sql + source: my-foodiefind-db + description: Find a restaurant's ID by its exact name. + parameters: + - name: name + type: string + description: The name of the restaurant to find. + statement: SELECT id FROM restaurants WHERE name = $1; + find_review_by_user_and_restaurant: + kind: postgres-sql + source: my-foodiefind-db + description: Find the full record for a specific review using the user's ID and the restaurant's ID. + parameters: + - name: user_id + type: integer + description: The numerical ID of the user. + - name: restaurant_id + type: integer + description: The numerical ID of the restaurant. + statement: SELECT * FROM reviews WHERE user_id = $1 AND restaurant_id = $2; +prompts: + investigate_missing_review: + description: "Investigates a user's missing review by finding the user, restaurant, and the review itself, then analyzing its status." + arguments: + - name: "user_email" + description: "The email of the user who wrote the review." + - name: "restaurant_name" + description: "The name of the restaurant being reviewed." + messages: + - content: >- + **Goal:** Find the review written by the user with email '{{.user_email}}' for the restaurant named '{{.restaurant_name}}' and understand its status. + **Workflow:** + 1. Use the `find_user_by_email` tool with the email '{{.user_email}}' to get the `user_id`. + 2. Use the `find_restaurant_by_name` tool with the name '{{.restaurant_name}}' to get the `restaurant_id`. + 3. Use the `find_review_by_user_and_restaurant` tool with the `user_id` and `restaurant_id` you just found. + 4. Analyze the results from the final tool call. Examine the `is_published` and `moderation_status` fields and explain the review's status to the user in a clear, human-readable sentence. +``` + +## Step 3: Connect to Gemini CLI + +Configure the Gemini CLI to talk to your local Toolbox MCP server. + +1. Open or create your Gemini settings file: `~/.gemini/settings.json`. +2. Add the following configuration to the file: + + ```json + { + "mcpServers": { + "MCPToolbox": { + "httpUrl": "http://localhost:5000/mcp" + } + }, + "mcp": { + "allowed": ["MCPToolbox"] + } + } + ``` +3. Start Gemini CLI using + ```sh + gemini + ``` + In case Gemini CLI is already running, use `/mcp refresh` to refresh the MCP server. + +4. Use gemini slash commands to run your prompt: + ```sh + /investigate_missing_review --user_email="jane.d@example.com" --restaurant_name="The Corner Bistro" + ``` diff --git a/go.mod b/go.mod index 09c08680772e..b2fb295beda9 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/json-iterator/go v1.1.12 - github.com/looker-open-source/sdk-codegen/go v0.25.18 + github.com/looker-open-source/sdk-codegen/go v0.25.21 github.com/microsoft/go-mssqldb v1.9.3 github.com/nakagami/firebirdsql v0.9.15 github.com/neo4j/neo4j-go-driver/v5 v5.28.4 diff --git a/go.sum b/go.sum index ba0cf4d8ce2f..d76e60f469e3 100644 --- a/go.sum +++ b/go.sum @@ -1134,8 +1134,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/looker-open-source/sdk-codegen/go v0.25.18 h1:me1JBFRnOBCrDWwpoSUVDVDFcFmcYMR2ijbx6ATtwTs= -github.com/looker-open-source/sdk-codegen/go v0.25.18/go.mod h1:Br1ntSiruDJ/4nYNjpYyWyCbqJ7+GQceWbIgn0hYims= +github.com/looker-open-source/sdk-codegen/go v0.25.21 h1:nlZ1nz22SKluBNkzplrMHBPEVgJO3zVLF6aAws1rrRA= +github.com/looker-open-source/sdk-codegen/go v0.25.21/go.mod h1:Br1ntSiruDJ/4nYNjpYyWyCbqJ7+GQceWbIgn0hYims= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= diff --git a/internal/tools/clickhouse/clickhouselisttables/clickhouselisttables.go b/internal/tools/clickhouse/clickhouselisttables/clickhouselisttables.go index 094fb28b02ed..16a3b4591135 100644 --- a/internal/tools/clickhouse/clickhouselisttables/clickhouselisttables.go +++ b/internal/tools/clickhouse/clickhouselisttables/clickhouselisttables.go @@ -121,7 +121,7 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para } defer results.Close() - var tables []map[string]any + tables := []map[string]any{} for results.Next() { var tableName string err := results.Scan(&tableName) diff --git a/internal/tools/mssql/mssqllisttables/mssqllisttables.go b/internal/tools/mssql/mssqllisttables/mssqllisttables.go index 4bb186db4345..03341132e223 100644 --- a/internal/tools/mssql/mssqllisttables/mssqllisttables.go +++ b/internal/tools/mssql/mssqllisttables/mssqllisttables.go @@ -391,7 +391,7 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para values[i] = &rawValues[i] } - var out []any + out := []any{} for rows.Next() { err = rows.Scan(values...) if err != nil { diff --git a/internal/tools/mysql/mysqllisttables/mysqllisttables.go b/internal/tools/mysql/mysqllisttables/mysqllisttables.go index e2b21aedaf20..ef4c9e666685 100644 --- a/internal/tools/mysql/mysqllisttables/mysqllisttables.go +++ b/internal/tools/mysql/mysqllisttables/mysqllisttables.go @@ -300,7 +300,7 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para return nil, fmt.Errorf("unable to get column types: %w", err) } - var out []any + out := []any{} for results.Next() { err := results.Scan(values...) if err != nil { diff --git a/internal/tools/postgres/postgreslisttables/postgreslisttables.go b/internal/tools/postgres/postgreslisttables/postgreslisttables.go index 85b6ed5f77dc..5e949a755ec2 100644 --- a/internal/tools/postgres/postgreslisttables/postgreslisttables.go +++ b/internal/tools/postgres/postgreslisttables/postgreslisttables.go @@ -210,7 +210,7 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para defer results.Close() fields := results.FieldDescriptions() - var out []map[string]any + out := []map[string]any{} for results.Next() { values, err := results.Values() diff --git a/internal/tools/spanner/spannerlisttables/spannerlisttables.go b/internal/tools/spanner/spannerlisttables/spannerlisttables.go index 3238d885b565..b5d361ea1289 100644 --- a/internal/tools/spanner/spannerlisttables/spannerlisttables.go +++ b/internal/tools/spanner/spannerlisttables/spannerlisttables.go @@ -129,7 +129,7 @@ type Tool struct { // processRows iterates over the spanner.RowIterator and converts each row to a map[string]any. func processRows(iter *spanner.RowIterator) ([]any, error) { - var out []any + out := []any{} defer iter.Stop() for { diff --git a/tests/mariadb/mariadb_integration_test.go b/tests/mariadb/mariadb_integration_test.go index 7f13e36206ba..60d734ace73e 100644 --- a/tests/mariadb/mariadb_integration_test.go +++ b/tests/mariadb/mariadb_integration_test.go @@ -250,7 +250,7 @@ func RunMariDBListTablesTest(t *testing.T, databaseName, tableNameParam, tableNa name: "invoke list_tables with non-existent table", requestBody: bytes.NewBufferString(`{"table_names": "non_existent_table"}`), wantStatusCode: http.StatusOK, - want: nil, + want: []objectDetails{}, }, } for _, tc := range invokeTcs { @@ -282,7 +282,7 @@ func RunMariDBListTablesTest(t *testing.T, databaseName, tableNameParam, tableNa if err := json.Unmarshal([]byte(resultString), &tables); err != nil { t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) } - var details []map[string]any + details := []map[string]any{} for _, table := range tables { var d map[string]any if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { @@ -292,23 +292,19 @@ func RunMariDBListTablesTest(t *testing.T, databaseName, tableNameParam, tableNa } got = details } else { - if resultString == "null" { - got = nil - } else { - var tables []tableInfo - if err := json.Unmarshal([]byte(resultString), &tables); err != nil { - t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) - } - var details []objectDetails - for _, table := range tables { - var d objectDetails - if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { - t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err) - } - details = append(details, d) + var tables []tableInfo + if err := json.Unmarshal([]byte(resultString), &tables); err != nil { + t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) + } + details := []objectDetails{} + for _, table := range tables { + var d objectDetails + if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { + t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err) } - got = details + details = append(details, d) } + got = details } opts := []cmp.Option{ @@ -319,7 +315,7 @@ func RunMariDBListTablesTest(t *testing.T, databaseName, tableNameParam, tableNa // Checking only the current database where the test tables are created to avoid brittle tests. if tc.isAllTables { - var filteredGot []objectDetails + filteredGot := []objectDetails{} if got != nil { for _, item := range got.([]objectDetails) { if item.SchemaName == databaseName { @@ -327,11 +323,7 @@ func RunMariDBListTablesTest(t *testing.T, databaseName, tableNameParam, tableNa } } } - if len(filteredGot) == 0 { - got = nil - } else { - got = filteredGot - } + got = filteredGot } if diff := cmp.Diff(tc.want, got, opts...); diff != "" { diff --git a/tests/tool.go b/tests/tool.go index c31e404645f8..9fcd045d766f 100644 --- a/tests/tool.go +++ b/tests/tool.go @@ -1189,7 +1189,7 @@ func RunPostgresListTablesTest(t *testing.T, tableNameParam, tableNameAuth, user api: "http://127.0.0.1:5000/api/tool/list_tables/invoke", requestBody: bytes.NewBuffer([]byte(`{"table_names": "non_existent_table"}`)), wantStatusCode: http.StatusOK, - want: `null`, + want: `[]`, }, { name: "invoke list_tables with one existing and one non-existent table", @@ -2822,7 +2822,7 @@ func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNam name: "invoke list_tables with non-existent table", requestBody: bytes.NewBufferString(`{"table_names": "non_existent_table"}`), wantStatusCode: http.StatusOK, - want: nil, + want: []objectDetails{}, }, } for _, tc := range invokeTcs { @@ -2854,7 +2854,7 @@ func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNam if err := json.Unmarshal([]byte(resultString), &tables); err != nil { t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) } - var details []map[string]any + details := []map[string]any{} for _, table := range tables { var d map[string]any if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { @@ -2864,23 +2864,19 @@ func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNam } got = details } else { - if resultString == "null" { - got = nil - } else { - var tables []tableInfo - if err := json.Unmarshal([]byte(resultString), &tables); err != nil { - t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) - } - var details []objectDetails - for _, table := range tables { - var d objectDetails - if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { - t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err) - } - details = append(details, d) + var tables []tableInfo + if err := json.Unmarshal([]byte(resultString), &tables); err != nil { + t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err) + } + details := []objectDetails{} + for _, table := range tables { + var d objectDetails + if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil { + t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err) } - got = details + details = append(details, d) } + got = details } opts := []cmp.Option{ @@ -2891,7 +2887,7 @@ func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNam // Checking only the current database where the test tables are created to avoid brittle tests. if tc.isAllTables { - var filteredGot []objectDetails + filteredGot := []objectDetails{} if got != nil { for _, item := range got.([]objectDetails) { if item.SchemaName == databaseName { @@ -2899,11 +2895,7 @@ func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNam } } } - if len(filteredGot) == 0 { - got = nil - } else { - got = filteredGot - } + got = filteredGot } if diff := cmp.Diff(tc.want, got, opts...); diff != "" { @@ -3491,7 +3483,7 @@ func RunMSSQLListTablesTest(t *testing.T, tableNameParam, tableNameAuth string) api: "http://127.0.0.1:5000/api/tool/list_tables/invoke", requestBody: `{"table_names": "non_existent_table"}`, wantStatusCode: http.StatusOK, - want: `null`, + want: `[]`, }, { name: "invoke list_tables with one existing and one non-existent table",