This is page 17 of 35. Use http://codebase.md/googleapis/genai-toolbox?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .ci │ ├── continuous.release.cloudbuild.yaml │ ├── generate_release_table.sh │ ├── integration.cloudbuild.yaml │ ├── quickstart_test │ │ ├── go.integration.cloudbuild.yaml │ │ ├── js.integration.cloudbuild.yaml │ │ ├── py.integration.cloudbuild.yaml │ │ ├── run_go_tests.sh │ │ ├── run_js_tests.sh │ │ ├── run_py_tests.sh │ │ └── setup_hotels_sample.sql │ ├── test_with_coverage.sh │ └── versioned.release.cloudbuild.yaml ├── .github │ ├── auto-label.yaml │ ├── blunderbuss.yml │ ├── CODEOWNERS │ ├── header-checker-lint.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ └── question.yml │ ├── label-sync.yml │ ├── labels.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── release-please.yml │ ├── renovate.json5 │ ├── sync-repo-settings.yaml │ └── workflows │ ├── cloud_build_failure_reporter.yml │ ├── deploy_dev_docs.yaml │ ├── deploy_previous_version_docs.yaml │ ├── deploy_versioned_docs.yaml │ ├── docs_deploy.yaml │ ├── docs_preview_clean.yaml │ ├── docs_preview_deploy.yaml │ ├── lint.yaml │ ├── schedule_reporter.yml │ ├── sync-labels.yaml │ └── tests.yaml ├── .gitignore ├── .gitmodules ├── .golangci.yaml ├── .hugo │ ├── archetypes │ │ └── default.md │ ├── assets │ │ ├── icons │ │ │ └── logo.svg │ │ └── scss │ │ ├── _styles_project.scss │ │ └── _variables_project.scss │ ├── go.mod │ ├── go.sum │ ├── hugo.toml │ ├── layouts │ │ ├── _default │ │ │ └── home.releases.releases │ │ ├── index.llms-full.txt │ │ ├── index.llms.txt │ │ ├── partials │ │ │ ├── hooks │ │ │ │ └── head-end.html │ │ │ ├── navbar-version-selector.html │ │ │ ├── page-meta-links.html │ │ │ └── td │ │ │ └── render-heading.html │ │ ├── robot.txt │ │ └── shortcodes │ │ ├── include.html │ │ ├── ipynb.html │ │ └── regionInclude.html │ ├── package-lock.json │ ├── package.json │ └── static │ ├── favicons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon.ico │ └── js │ └── w3.js ├── CHANGELOG.md ├── cmd │ ├── options_test.go │ ├── options.go │ ├── root_test.go │ ├── root.go │ └── version.txt ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPER.md ├── Dockerfile ├── docs │ └── en │ ├── _index.md │ ├── about │ │ ├── _index.md │ │ └── faq.md │ ├── concepts │ │ ├── _index.md │ │ └── telemetry │ │ ├── index.md │ │ ├── telemetry_flow.png │ │ └── telemetry_traces.png │ ├── getting-started │ │ ├── _index.md │ │ ├── colab_quickstart.ipynb │ │ ├── configure.md │ │ ├── introduction │ │ │ ├── _index.md │ │ │ └── architecture.png │ │ ├── local_quickstart_go.md │ │ ├── local_quickstart_js.md │ │ ├── local_quickstart.md │ │ ├── mcp_quickstart │ │ │ ├── _index.md │ │ │ ├── inspector_tools.png │ │ │ └── inspector.png │ │ └── quickstart │ │ ├── go │ │ │ ├── genAI │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── quickstart.go │ │ │ ├── genkit │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── quickstart.go │ │ │ ├── langchain │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── quickstart.go │ │ │ ├── openAI │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── quickstart.go │ │ │ └── quickstart_test.go │ │ ├── golden.txt │ │ ├── js │ │ │ ├── genAI │ │ │ │ ├── package-lock.json │ │ │ │ ├── package.json │ │ │ │ └── quickstart.js │ │ │ ├── genkit │ │ │ │ ├── package-lock.json │ │ │ │ ├── package.json │ │ │ │ └── quickstart.js │ │ │ ├── langchain │ │ │ │ ├── package-lock.json │ │ │ │ ├── package.json │ │ │ │ └── quickstart.js │ │ │ ├── llamaindex │ │ │ │ ├── package-lock.json │ │ │ │ ├── package.json │ │ │ │ └── quickstart.js │ │ │ └── quickstart.test.js │ │ ├── python │ │ │ ├── __init__.py │ │ │ ├── adk │ │ │ │ ├── quickstart.py │ │ │ │ └── requirements.txt │ │ │ ├── core │ │ │ │ ├── quickstart.py │ │ │ │ └── requirements.txt │ │ │ ├── langchain │ │ │ │ ├── quickstart.py │ │ │ │ └── requirements.txt │ │ │ ├── llamaindex │ │ │ │ ├── quickstart.py │ │ │ │ └── requirements.txt │ │ │ └── quickstart_test.py │ │ └── shared │ │ ├── cloud_setup.md │ │ ├── configure_toolbox.md │ │ └── database_setup.md │ ├── how-to │ │ ├── _index.md │ │ ├── connect_via_geminicli.md │ │ ├── connect_via_mcp.md │ │ ├── connect-ide │ │ │ ├── _index.md │ │ │ ├── alloydb_pg_admin_mcp.md │ │ │ ├── alloydb_pg_mcp.md │ │ │ ├── bigquery_mcp.md │ │ │ ├── cloud_sql_mssql_admin_mcp.md │ │ │ ├── cloud_sql_mssql_mcp.md │ │ │ ├── cloud_sql_mysql_admin_mcp.md │ │ │ ├── cloud_sql_mysql_mcp.md │ │ │ ├── cloud_sql_pg_admin_mcp.md │ │ │ ├── cloud_sql_pg_mcp.md │ │ │ ├── firestore_mcp.md │ │ │ ├── looker_mcp.md │ │ │ ├── mssql_mcp.md │ │ │ ├── mysql_mcp.md │ │ │ ├── neo4j_mcp.md │ │ │ ├── postgres_mcp.md │ │ │ ├── spanner_mcp.md │ │ │ └── sqlite_mcp.md │ │ ├── deploy_docker.md │ │ ├── deploy_gke.md │ │ ├── deploy_toolbox.md │ │ ├── export_telemetry.md │ │ └── toolbox-ui │ │ ├── edit-headers.gif │ │ ├── edit-headers.png │ │ ├── index.md │ │ ├── optional-param-checked.png │ │ ├── optional-param-unchecked.png │ │ ├── run-tool.gif │ │ ├── tools.png │ │ └── toolsets.png │ ├── reference │ │ ├── _index.md │ │ ├── cli.md │ │ └── prebuilt-tools.md │ ├── resources │ │ ├── _index.md │ │ ├── authServices │ │ │ ├── _index.md │ │ │ └── google.md │ │ ├── sources │ │ │ ├── _index.md │ │ │ ├── alloydb-admin.md │ │ │ ├── alloydb-pg.md │ │ │ ├── bigquery.md │ │ │ ├── bigtable.md │ │ │ ├── cassandra.md │ │ │ ├── clickhouse.md │ │ │ ├── cloud-monitoring.md │ │ │ ├── cloud-sql-admin.md │ │ │ ├── cloud-sql-mssql.md │ │ │ ├── cloud-sql-mysql.md │ │ │ ├── cloud-sql-pg.md │ │ │ ├── couchbase.md │ │ │ ├── dataplex.md │ │ │ ├── dgraph.md │ │ │ ├── firebird.md │ │ │ ├── firestore.md │ │ │ ├── http.md │ │ │ ├── looker.md │ │ │ ├── mongodb.md │ │ │ ├── mssql.md │ │ │ ├── mysql.md │ │ │ ├── neo4j.md │ │ │ ├── oceanbase.md │ │ │ ├── oracle.md │ │ │ ├── postgres.md │ │ │ ├── redis.md │ │ │ ├── spanner.md │ │ │ ├── sqlite.md │ │ │ ├── tidb.md │ │ │ ├── trino.md │ │ │ ├── valkey.md │ │ │ └── yugabytedb.md │ │ └── tools │ │ ├── _index.md │ │ ├── alloydb │ │ │ ├── _index.md │ │ │ ├── alloydb-create-cluster.md │ │ │ ├── alloydb-create-instance.md │ │ │ ├── alloydb-create-user.md │ │ │ ├── alloydb-get-cluster.md │ │ │ ├── alloydb-get-instance.md │ │ │ ├── alloydb-get-user.md │ │ │ ├── alloydb-list-clusters.md │ │ │ ├── alloydb-list-instances.md │ │ │ ├── alloydb-list-users.md │ │ │ └── alloydb-wait-for-operation.md │ │ ├── alloydbainl │ │ │ ├── _index.md │ │ │ └── alloydb-ai-nl.md │ │ ├── bigquery │ │ │ ├── _index.md │ │ │ ├── bigquery-analyze-contribution.md │ │ │ ├── bigquery-conversational-analytics.md │ │ │ ├── bigquery-execute-sql.md │ │ │ ├── bigquery-forecast.md │ │ │ ├── bigquery-get-dataset-info.md │ │ │ ├── bigquery-get-table-info.md │ │ │ ├── bigquery-list-dataset-ids.md │ │ │ ├── bigquery-list-table-ids.md │ │ │ ├── bigquery-search-catalog.md │ │ │ └── bigquery-sql.md │ │ ├── bigtable │ │ │ ├── _index.md │ │ │ └── bigtable-sql.md │ │ ├── cassandra │ │ │ ├── _index.md │ │ │ └── cassandra-cql.md │ │ ├── clickhouse │ │ │ ├── _index.md │ │ │ ├── clickhouse-execute-sql.md │ │ │ ├── clickhouse-list-databases.md │ │ │ ├── clickhouse-list-tables.md │ │ │ └── clickhouse-sql.md │ │ ├── cloudmonitoring │ │ │ ├── _index.md │ │ │ └── cloud-monitoring-query-prometheus.md │ │ ├── cloudsql │ │ │ ├── _index.md │ │ │ ├── cloudsqlcreatedatabase.md │ │ │ ├── cloudsqlcreateusers.md │ │ │ ├── cloudsqlgetinstances.md │ │ │ ├── cloudsqllistdatabases.md │ │ │ ├── cloudsqllistinstances.md │ │ │ ├── cloudsqlmssqlcreateinstance.md │ │ │ ├── cloudsqlmysqlcreateinstance.md │ │ │ ├── cloudsqlpgcreateinstances.md │ │ │ └── cloudsqlwaitforoperation.md │ │ ├── couchbase │ │ │ ├── _index.md │ │ │ └── couchbase-sql.md │ │ ├── dataform │ │ │ ├── _index.md │ │ │ └── dataform-compile-local.md │ │ ├── dataplex │ │ │ ├── _index.md │ │ │ ├── dataplex-lookup-entry.md │ │ │ ├── dataplex-search-aspect-types.md │ │ │ └── dataplex-search-entries.md │ │ ├── dgraph │ │ │ ├── _index.md │ │ │ └── dgraph-dql.md │ │ ├── firebird │ │ │ ├── _index.md │ │ │ ├── firebird-execute-sql.md │ │ │ └── firebird-sql.md │ │ ├── firestore │ │ │ ├── _index.md │ │ │ ├── firestore-add-documents.md │ │ │ ├── firestore-delete-documents.md │ │ │ ├── firestore-get-documents.md │ │ │ ├── firestore-get-rules.md │ │ │ ├── firestore-list-collections.md │ │ │ ├── firestore-query-collection.md │ │ │ ├── firestore-query.md │ │ │ ├── firestore-update-document.md │ │ │ └── firestore-validate-rules.md │ │ ├── http │ │ │ ├── _index.md │ │ │ └── http.md │ │ ├── looker │ │ │ ├── _index.md │ │ │ ├── looker-add-dashboard-element.md │ │ │ ├── looker-conversational-analytics.md │ │ │ ├── looker-create-project-file.md │ │ │ ├── looker-delete-project-file.md │ │ │ ├── looker-dev-mode.md │ │ │ ├── looker-get-dashboards.md │ │ │ ├── looker-get-dimensions.md │ │ │ ├── looker-get-explores.md │ │ │ ├── looker-get-filters.md │ │ │ ├── looker-get-looks.md │ │ │ ├── looker-get-measures.md │ │ │ ├── looker-get-models.md │ │ │ ├── looker-get-parameters.md │ │ │ ├── looker-get-project-file.md │ │ │ ├── looker-get-project-files.md │ │ │ ├── looker-get-projects.md │ │ │ ├── looker-health-analyze.md │ │ │ ├── looker-health-pulse.md │ │ │ ├── looker-health-vacuum.md │ │ │ ├── looker-make-dashboard.md │ │ │ ├── looker-make-look.md │ │ │ ├── looker-query-sql.md │ │ │ ├── looker-query-url.md │ │ │ ├── looker-query.md │ │ │ ├── looker-run-look.md │ │ │ └── looker-update-project-file.md │ │ ├── mongodb │ │ │ ├── _index.md │ │ │ ├── mongodb-aggregate.md │ │ │ ├── mongodb-delete-many.md │ │ │ ├── mongodb-delete-one.md │ │ │ ├── mongodb-find-one.md │ │ │ ├── mongodb-find.md │ │ │ ├── mongodb-insert-many.md │ │ │ ├── mongodb-insert-one.md │ │ │ ├── mongodb-update-many.md │ │ │ └── mongodb-update-one.md │ │ ├── mssql │ │ │ ├── _index.md │ │ │ ├── mssql-execute-sql.md │ │ │ ├── mssql-list-tables.md │ │ │ └── mssql-sql.md │ │ ├── mysql │ │ │ ├── _index.md │ │ │ ├── mysql-execute-sql.md │ │ │ ├── mysql-list-active-queries.md │ │ │ ├── mysql-list-table-fragmentation.md │ │ │ ├── mysql-list-tables-missing-unique-indexes.md │ │ │ ├── mysql-list-tables.md │ │ │ └── mysql-sql.md │ │ ├── neo4j │ │ │ ├── _index.md │ │ │ ├── neo4j-cypher.md │ │ │ ├── neo4j-execute-cypher.md │ │ │ └── neo4j-schema.md │ │ ├── oceanbase │ │ │ ├── _index.md │ │ │ ├── oceanbase-execute-sql.md │ │ │ └── oceanbase-sql.md │ │ ├── oracle │ │ │ ├── _index.md │ │ │ ├── oracle-execute-sql.md │ │ │ └── oracle-sql.md │ │ ├── postgres │ │ │ ├── _index.md │ │ │ ├── postgres-execute-sql.md │ │ │ ├── postgres-list-active-queries.md │ │ │ ├── postgres-list-available-extensions.md │ │ │ ├── postgres-list-installed-extensions.md │ │ │ ├── postgres-list-tables.md │ │ │ └── postgres-sql.md │ │ ├── redis │ │ │ ├── _index.md │ │ │ └── redis.md │ │ ├── spanner │ │ │ ├── _index.md │ │ │ ├── spanner-execute-sql.md │ │ │ ├── spanner-list-tables.md │ │ │ └── spanner-sql.md │ │ ├── sqlite │ │ │ ├── _index.md │ │ │ ├── sqlite-execute-sql.md │ │ │ └── sqlite-sql.md │ │ ├── tidb │ │ │ ├── _index.md │ │ │ ├── tidb-execute-sql.md │ │ │ └── tidb-sql.md │ │ ├── trino │ │ │ ├── _index.md │ │ │ ├── trino-execute-sql.md │ │ │ └── trino-sql.md │ │ ├── utility │ │ │ ├── _index.md │ │ │ └── wait.md │ │ ├── valkey │ │ │ ├── _index.md │ │ │ └── valkey.md │ │ └── yuagbytedb │ │ ├── _index.md │ │ └── yugabytedb-sql.md │ ├── samples │ │ ├── _index.md │ │ ├── alloydb │ │ │ ├── _index.md │ │ │ ├── ai-nl │ │ │ │ ├── alloydb_ai_nl.ipynb │ │ │ │ └── index.md │ │ │ └── mcp_quickstart.md │ │ ├── bigquery │ │ │ ├── _index.md │ │ │ ├── colab_quickstart_bigquery.ipynb │ │ │ ├── local_quickstart.md │ │ │ └── mcp_quickstart │ │ │ ├── _index.md │ │ │ ├── inspector_tools.png │ │ │ └── inspector.png │ │ └── looker │ │ ├── _index.md │ │ ├── looker_gemini_oauth │ │ │ ├── _index.md │ │ │ ├── authenticated.png │ │ │ ├── authorize.png │ │ │ └── registration.png │ │ ├── looker_gemini.md │ │ └── looker_mcp_inspector │ │ ├── _index.md │ │ ├── inspector_tools.png │ │ └── inspector.png │ └── sdks │ ├── _index.md │ ├── go-sdk.md │ ├── js-sdk.md │ └── python-sdk.md ├── gemini-extension.json ├── go.mod ├── go.sum ├── internal │ ├── auth │ │ ├── auth.go │ │ └── google │ │ └── google.go │ ├── log │ │ ├── handler.go │ │ ├── log_test.go │ │ ├── log.go │ │ └── logger.go │ ├── prebuiltconfigs │ │ ├── prebuiltconfigs_test.go │ │ ├── prebuiltconfigs.go │ │ └── tools │ │ ├── alloydb-postgres-admin.yaml │ │ ├── alloydb-postgres-observability.yaml │ │ ├── alloydb-postgres.yaml │ │ ├── bigquery.yaml │ │ ├── clickhouse.yaml │ │ ├── cloud-sql-mssql-admin.yaml │ │ ├── cloud-sql-mssql-observability.yaml │ │ ├── cloud-sql-mssql.yaml │ │ ├── cloud-sql-mysql-admin.yaml │ │ ├── cloud-sql-mysql-observability.yaml │ │ ├── cloud-sql-mysql.yaml │ │ ├── cloud-sql-postgres-admin.yaml │ │ ├── cloud-sql-postgres-observability.yaml │ │ ├── cloud-sql-postgres.yaml │ │ ├── dataplex.yaml │ │ ├── firestore.yaml │ │ ├── looker-conversational-analytics.yaml │ │ ├── looker.yaml │ │ ├── mssql.yaml │ │ ├── mysql.yaml │ │ ├── neo4j.yaml │ │ ├── oceanbase.yaml │ │ ├── postgres.yaml │ │ ├── spanner-postgres.yaml │ │ ├── spanner.yaml │ │ └── sqlite.yaml │ ├── server │ │ ├── api_test.go │ │ ├── api.go │ │ ├── common_test.go │ │ ├── config.go │ │ ├── mcp │ │ │ ├── jsonrpc │ │ │ │ ├── jsonrpc_test.go │ │ │ │ └── jsonrpc.go │ │ │ ├── mcp.go │ │ │ ├── util │ │ │ │ └── lifecycle.go │ │ │ ├── v20241105 │ │ │ │ ├── method.go │ │ │ │ └── types.go │ │ │ ├── v20250326 │ │ │ │ ├── method.go │ │ │ │ └── types.go │ │ │ └── v20250618 │ │ │ ├── method.go │ │ │ └── types.go │ │ ├── mcp_test.go │ │ ├── mcp.go │ │ ├── server_test.go │ │ ├── server.go │ │ ├── static │ │ │ ├── assets │ │ │ │ └── mcptoolboxlogo.png │ │ │ ├── css │ │ │ │ └── style.css │ │ │ ├── index.html │ │ │ ├── js │ │ │ │ ├── auth.js │ │ │ │ ├── loadTools.js │ │ │ │ ├── mainContent.js │ │ │ │ ├── navbar.js │ │ │ │ ├── runTool.js │ │ │ │ ├── toolDisplay.js │ │ │ │ ├── tools.js │ │ │ │ └── toolsets.js │ │ │ ├── tools.html │ │ │ └── toolsets.html │ │ ├── web_test.go │ │ └── web.go │ ├── sources │ │ ├── alloydbadmin │ │ │ ├── alloydbadmin_test.go │ │ │ └── alloydbadmin.go │ │ ├── alloydbpg │ │ │ ├── alloydb_pg_test.go │ │ │ └── alloydb_pg.go │ │ ├── bigquery │ │ │ ├── bigquery_test.go │ │ │ └── bigquery.go │ │ ├── bigtable │ │ │ ├── bigtable_test.go │ │ │ └── bigtable.go │ │ ├── cassandra │ │ │ ├── cassandra_test.go │ │ │ └── cassandra.go │ │ ├── clickhouse │ │ │ ├── clickhouse_test.go │ │ │ └── clickhouse.go │ │ ├── cloudmonitoring │ │ │ ├── cloud_monitoring_test.go │ │ │ └── cloud_monitoring.go │ │ ├── cloudsqladmin │ │ │ ├── cloud_sql_admin_test.go │ │ │ └── cloud_sql_admin.go │ │ ├── cloudsqlmssql │ │ │ ├── cloud_sql_mssql_test.go │ │ │ └── cloud_sql_mssql.go │ │ ├── cloudsqlmysql │ │ │ ├── cloud_sql_mysql_test.go │ │ │ └── cloud_sql_mysql.go │ │ ├── cloudsqlpg │ │ │ ├── cloud_sql_pg_test.go │ │ │ └── cloud_sql_pg.go │ │ ├── couchbase │ │ │ ├── couchbase_test.go │ │ │ └── couchbase.go │ │ ├── dataplex │ │ │ ├── dataplex_test.go │ │ │ └── dataplex.go │ │ ├── dgraph │ │ │ ├── dgraph_test.go │ │ │ └── dgraph.go │ │ ├── dialect.go │ │ ├── firebird │ │ │ ├── firebird_test.go │ │ │ └── firebird.go │ │ ├── firestore │ │ │ ├── firestore_test.go │ │ │ └── firestore.go │ │ ├── http │ │ │ ├── http_test.go │ │ │ └── http.go │ │ ├── ip_type.go │ │ ├── looker │ │ │ ├── looker_test.go │ │ │ └── looker.go │ │ ├── mongodb │ │ │ ├── mongodb_test.go │ │ │ └── mongodb.go │ │ ├── mssql │ │ │ ├── mssql_test.go │ │ │ └── mssql.go │ │ ├── mysql │ │ │ ├── mysql_test.go │ │ │ └── mysql.go │ │ ├── neo4j │ │ │ ├── neo4j_test.go │ │ │ └── neo4j.go │ │ ├── oceanbase │ │ │ ├── oceanbase_test.go │ │ │ └── oceanbase.go │ │ ├── oracle │ │ │ └── oracle.go │ │ ├── postgres │ │ │ ├── postgres_test.go │ │ │ └── postgres.go │ │ ├── redis │ │ │ ├── redis_test.go │ │ │ └── redis.go │ │ ├── sources.go │ │ ├── spanner │ │ │ ├── spanner_test.go │ │ │ └── spanner.go │ │ ├── sqlite │ │ │ ├── sqlite_test.go │ │ │ └── sqlite.go │ │ ├── tidb │ │ │ ├── tidb_test.go │ │ │ └── tidb.go │ │ ├── trino │ │ │ ├── trino_test.go │ │ │ └── trino.go │ │ ├── util.go │ │ ├── valkey │ │ │ ├── valkey_test.go │ │ │ └── valkey.go │ │ └── yugabytedb │ │ ├── yugabytedb_test.go │ │ └── yugabytedb.go │ ├── telemetry │ │ ├── instrumentation.go │ │ └── telemetry.go │ ├── testutils │ │ └── testutils.go │ ├── tools │ │ ├── alloydb │ │ │ ├── alloydbcreatecluster │ │ │ │ ├── alloydbcreatecluster_test.go │ │ │ │ └── alloydbcreatecluster.go │ │ │ ├── alloydbcreateinstance │ │ │ │ ├── alloydbcreateinstance_test.go │ │ │ │ └── alloydbcreateinstance.go │ │ │ ├── alloydbcreateuser │ │ │ │ ├── alloydbcreateuser_test.go │ │ │ │ └── alloydbcreateuser.go │ │ │ ├── alloydbgetcluster │ │ │ │ ├── alloydbgetcluster_test.go │ │ │ │ └── alloydbgetcluster.go │ │ │ ├── alloydbgetinstance │ │ │ │ ├── alloydbgetinstance_test.go │ │ │ │ └── alloydbgetinstance.go │ │ │ ├── alloydbgetuser │ │ │ │ ├── alloydbgetuser_test.go │ │ │ │ └── alloydbgetuser.go │ │ │ ├── alloydblistclusters │ │ │ │ ├── alloydblistclusters_test.go │ │ │ │ └── alloydblistclusters.go │ │ │ ├── alloydblistinstances │ │ │ │ ├── alloydblistinstances_test.go │ │ │ │ └── alloydblistinstances.go │ │ │ ├── alloydblistusers │ │ │ │ ├── alloydblistusers_test.go │ │ │ │ └── alloydblistusers.go │ │ │ └── alloydbwaitforoperation │ │ │ ├── alloydbwaitforoperation_test.go │ │ │ └── alloydbwaitforoperation.go │ │ ├── alloydbainl │ │ │ ├── alloydbainl_test.go │ │ │ └── alloydbainl.go │ │ ├── bigquery │ │ │ ├── bigqueryanalyzecontribution │ │ │ │ ├── bigqueryanalyzecontribution_test.go │ │ │ │ └── bigqueryanalyzecontribution.go │ │ │ ├── bigquerycommon │ │ │ │ ├── table_name_parser_test.go │ │ │ │ ├── table_name_parser.go │ │ │ │ └── util.go │ │ │ ├── bigqueryconversationalanalytics │ │ │ │ ├── bigqueryconversationalanalytics_test.go │ │ │ │ └── bigqueryconversationalanalytics.go │ │ │ ├── bigqueryexecutesql │ │ │ │ ├── bigqueryexecutesql_test.go │ │ │ │ └── bigqueryexecutesql.go │ │ │ ├── bigqueryforecast │ │ │ │ ├── bigqueryforecast_test.go │ │ │ │ └── bigqueryforecast.go │ │ │ ├── bigquerygetdatasetinfo │ │ │ │ ├── bigquerygetdatasetinfo_test.go │ │ │ │ └── bigquerygetdatasetinfo.go │ │ │ ├── bigquerygettableinfo │ │ │ │ ├── bigquerygettableinfo_test.go │ │ │ │ └── bigquerygettableinfo.go │ │ │ ├── bigquerylistdatasetids │ │ │ │ ├── bigquerylistdatasetids_test.go │ │ │ │ └── bigquerylistdatasetids.go │ │ │ ├── bigquerylisttableids │ │ │ │ ├── bigquerylisttableids_test.go │ │ │ │ └── bigquerylisttableids.go │ │ │ ├── bigquerysearchcatalog │ │ │ │ ├── bigquerysearchcatalog_test.go │ │ │ │ └── bigquerysearchcatalog.go │ │ │ └── bigquerysql │ │ │ ├── bigquerysql_test.go │ │ │ └── bigquerysql.go │ │ ├── bigtable │ │ │ ├── bigtable_test.go │ │ │ └── bigtable.go │ │ ├── cassandra │ │ │ └── cassandracql │ │ │ ├── cassandracql_test.go │ │ │ └── cassandracql.go │ │ ├── clickhouse │ │ │ ├── clickhouseexecutesql │ │ │ │ ├── clickhouseexecutesql_test.go │ │ │ │ └── clickhouseexecutesql.go │ │ │ ├── clickhouselistdatabases │ │ │ │ ├── clickhouselistdatabases_test.go │ │ │ │ └── clickhouselistdatabases.go │ │ │ ├── clickhouselisttables │ │ │ │ ├── clickhouselisttables_test.go │ │ │ │ └── clickhouselisttables.go │ │ │ └── clickhousesql │ │ │ ├── clickhousesql_test.go │ │ │ └── clickhousesql.go │ │ ├── cloudmonitoring │ │ │ ├── cloudmonitoring_test.go │ │ │ └── cloudmonitoring.go │ │ ├── cloudsql │ │ │ ├── cloudsqlcreatedatabase │ │ │ │ ├── cloudsqlcreatedatabase_test.go │ │ │ │ └── cloudsqlcreatedatabase.go │ │ │ ├── cloudsqlcreateusers │ │ │ │ ├── cloudsqlcreateusers_test.go │ │ │ │ └── cloudsqlcreateusers.go │ │ │ ├── cloudsqlgetinstances │ │ │ │ ├── cloudsqlgetinstances_test.go │ │ │ │ └── cloudsqlgetinstances.go │ │ │ ├── cloudsqllistdatabases │ │ │ │ ├── cloudsqllistdatabases_test.go │ │ │ │ └── cloudsqllistdatabases.go │ │ │ ├── cloudsqllistinstances │ │ │ │ ├── cloudsqllistinstances_test.go │ │ │ │ └── cloudsqllistinstances.go │ │ │ └── cloudsqlwaitforoperation │ │ │ ├── cloudsqlwaitforoperation_test.go │ │ │ └── cloudsqlwaitforoperation.go │ │ ├── cloudsqlmssql │ │ │ └── cloudsqlmssqlcreateinstance │ │ │ ├── cloudsqlmssqlcreateinstance_test.go │ │ │ └── cloudsqlmssqlcreateinstance.go │ │ ├── cloudsqlmysql │ │ │ └── cloudsqlmysqlcreateinstance │ │ │ ├── cloudsqlmysqlcreateinstance_test.go │ │ │ └── cloudsqlmysqlcreateinstance.go │ │ ├── cloudsqlpg │ │ │ └── cloudsqlpgcreateinstances │ │ │ ├── cloudsqlpgcreateinstances_test.go │ │ │ └── cloudsqlpgcreateinstances.go │ │ ├── common_test.go │ │ ├── common.go │ │ ├── couchbase │ │ │ ├── couchbase_test.go │ │ │ └── couchbase.go │ │ ├── dataform │ │ │ └── dataformcompilelocal │ │ │ ├── dataformcompilelocal_test.go │ │ │ └── dataformcompilelocal.go │ │ ├── dataplex │ │ │ ├── dataplexlookupentry │ │ │ │ ├── dataplexlookupentry_test.go │ │ │ │ └── dataplexlookupentry.go │ │ │ ├── dataplexsearchaspecttypes │ │ │ │ ├── dataplexsearchaspecttypes_test.go │ │ │ │ └── dataplexsearchaspecttypes.go │ │ │ └── dataplexsearchentries │ │ │ ├── dataplexsearchentries_test.go │ │ │ └── dataplexsearchentries.go │ │ ├── dgraph │ │ │ ├── dgraph_test.go │ │ │ └── dgraph.go │ │ ├── firebird │ │ │ ├── firebirdexecutesql │ │ │ │ ├── firebirdexecutesql_test.go │ │ │ │ └── firebirdexecutesql.go │ │ │ └── firebirdsql │ │ │ ├── firebirdsql_test.go │ │ │ └── firebirdsql.go │ │ ├── firestore │ │ │ ├── firestoreadddocuments │ │ │ │ ├── firestoreadddocuments_test.go │ │ │ │ └── firestoreadddocuments.go │ │ │ ├── firestoredeletedocuments │ │ │ │ ├── firestoredeletedocuments_test.go │ │ │ │ └── firestoredeletedocuments.go │ │ │ ├── firestoregetdocuments │ │ │ │ ├── firestoregetdocuments_test.go │ │ │ │ └── firestoregetdocuments.go │ │ │ ├── firestoregetrules │ │ │ │ ├── firestoregetrules_test.go │ │ │ │ └── firestoregetrules.go │ │ │ ├── firestorelistcollections │ │ │ │ ├── firestorelistcollections_test.go │ │ │ │ └── firestorelistcollections.go │ │ │ ├── firestorequery │ │ │ │ ├── firestorequery_test.go │ │ │ │ └── firestorequery.go │ │ │ ├── firestorequerycollection │ │ │ │ ├── firestorequerycollection_test.go │ │ │ │ └── firestorequerycollection.go │ │ │ ├── firestoreupdatedocument │ │ │ │ ├── firestoreupdatedocument_test.go │ │ │ │ └── firestoreupdatedocument.go │ │ │ ├── firestorevalidaterules │ │ │ │ ├── firestorevalidaterules_test.go │ │ │ │ └── firestorevalidaterules.go │ │ │ └── util │ │ │ ├── converter_test.go │ │ │ ├── converter.go │ │ │ ├── validator_test.go │ │ │ └── validator.go │ │ ├── http │ │ │ ├── http_test.go │ │ │ └── http.go │ │ ├── http_method.go │ │ ├── looker │ │ │ ├── lookeradddashboardelement │ │ │ │ ├── lookeradddashboardelement_test.go │ │ │ │ └── lookeradddashboardelement.go │ │ │ ├── lookercommon │ │ │ │ ├── lookercommon_test.go │ │ │ │ └── lookercommon.go │ │ │ ├── lookerconversationalanalytics │ │ │ │ ├── lookerconversationalanalytics_test.go │ │ │ │ └── lookerconversationalanalytics.go │ │ │ ├── lookercreateprojectfile │ │ │ │ ├── lookercreateprojectfile_test.go │ │ │ │ └── lookercreateprojectfile.go │ │ │ ├── lookerdeleteprojectfile │ │ │ │ ├── lookerdeleteprojectfile_test.go │ │ │ │ └── lookerdeleteprojectfile.go │ │ │ ├── lookerdevmode │ │ │ │ ├── lookerdevmode_test.go │ │ │ │ └── lookerdevmode.go │ │ │ ├── lookergetdashboards │ │ │ │ ├── lookergetdashboards_test.go │ │ │ │ └── lookergetdashboards.go │ │ │ ├── lookergetdimensions │ │ │ │ ├── lookergetdimensions_test.go │ │ │ │ └── lookergetdimensions.go │ │ │ ├── lookergetexplores │ │ │ │ ├── lookergetexplores_test.go │ │ │ │ └── lookergetexplores.go │ │ │ ├── lookergetfilters │ │ │ │ ├── lookergetfilters_test.go │ │ │ │ └── lookergetfilters.go │ │ │ ├── lookergetlooks │ │ │ │ ├── lookergetlooks_test.go │ │ │ │ └── lookergetlooks.go │ │ │ ├── lookergetmeasures │ │ │ │ ├── lookergetmeasures_test.go │ │ │ │ └── lookergetmeasures.go │ │ │ ├── lookergetmodels │ │ │ │ ├── lookergetmodels_test.go │ │ │ │ └── lookergetmodels.go │ │ │ ├── lookergetparameters │ │ │ │ ├── lookergetparameters_test.go │ │ │ │ └── lookergetparameters.go │ │ │ ├── lookergetprojectfile │ │ │ │ ├── lookergetprojectfile_test.go │ │ │ │ └── lookergetprojectfile.go │ │ │ ├── lookergetprojectfiles │ │ │ │ ├── lookergetprojectfiles_test.go │ │ │ │ └── lookergetprojectfiles.go │ │ │ ├── lookergetprojects │ │ │ │ ├── lookergetprojects_test.go │ │ │ │ └── lookergetprojects.go │ │ │ ├── lookerhealthanalyze │ │ │ │ ├── lookerhealthanalyze_test.go │ │ │ │ └── lookerhealthanalyze.go │ │ │ ├── lookerhealthpulse │ │ │ │ ├── lookerhealthpulse_test.go │ │ │ │ └── lookerhealthpulse.go │ │ │ ├── lookerhealthvacuum │ │ │ │ ├── lookerhealthvacuum_test.go │ │ │ │ └── lookerhealthvacuum.go │ │ │ ├── lookermakedashboard │ │ │ │ ├── lookermakedashboard_test.go │ │ │ │ └── lookermakedashboard.go │ │ │ ├── lookermakelook │ │ │ │ ├── lookermakelook_test.go │ │ │ │ └── lookermakelook.go │ │ │ ├── lookerquery │ │ │ │ ├── lookerquery_test.go │ │ │ │ └── lookerquery.go │ │ │ ├── lookerquerysql │ │ │ │ ├── lookerquerysql_test.go │ │ │ │ └── lookerquerysql.go │ │ │ ├── lookerqueryurl │ │ │ │ ├── lookerqueryurl_test.go │ │ │ │ └── lookerqueryurl.go │ │ │ ├── lookerrunlook │ │ │ │ ├── lookerrunlook_test.go │ │ │ │ └── lookerrunlook.go │ │ │ └── lookerupdateprojectfile │ │ │ ├── lookerupdateprojectfile_test.go │ │ │ └── lookerupdateprojectfile.go │ │ ├── mongodb │ │ │ ├── mongodbaggregate │ │ │ │ ├── mongodbaggregate_test.go │ │ │ │ └── mongodbaggregate.go │ │ │ ├── mongodbdeletemany │ │ │ │ ├── mongodbdeletemany_test.go │ │ │ │ └── mongodbdeletemany.go │ │ │ ├── mongodbdeleteone │ │ │ │ ├── mongodbdeleteone_test.go │ │ │ │ └── mongodbdeleteone.go │ │ │ ├── mongodbfind │ │ │ │ ├── mongodbfind_test.go │ │ │ │ └── mongodbfind.go │ │ │ ├── mongodbfindone │ │ │ │ ├── mongodbfindone_test.go │ │ │ │ └── mongodbfindone.go │ │ │ ├── mongodbinsertmany │ │ │ │ ├── mongodbinsertmany_test.go │ │ │ │ └── mongodbinsertmany.go │ │ │ ├── mongodbinsertone │ │ │ │ ├── mongodbinsertone_test.go │ │ │ │ └── mongodbinsertone.go │ │ │ ├── mongodbupdatemany │ │ │ │ ├── mongodbupdatemany_test.go │ │ │ │ └── mongodbupdatemany.go │ │ │ └── mongodbupdateone │ │ │ ├── mongodbupdateone_test.go │ │ │ └── mongodbupdateone.go │ │ ├── mssql │ │ │ ├── mssqlexecutesql │ │ │ │ ├── mssqlexecutesql_test.go │ │ │ │ └── mssqlexecutesql.go │ │ │ ├── mssqllisttables │ │ │ │ ├── mssqllisttables_test.go │ │ │ │ └── mssqllisttables.go │ │ │ └── mssqlsql │ │ │ ├── mssqlsql_test.go │ │ │ └── mssqlsql.go │ │ ├── mysql │ │ │ ├── mysqlcommon │ │ │ │ └── mysqlcommon.go │ │ │ ├── mysqlexecutesql │ │ │ │ ├── mysqlexecutesql_test.go │ │ │ │ └── mysqlexecutesql.go │ │ │ ├── mysqllistactivequeries │ │ │ │ ├── mysqllistactivequeries_test.go │ │ │ │ └── mysqllistactivequeries.go │ │ │ ├── mysqllisttablefragmentation │ │ │ │ ├── mysqllisttablefragmentation_test.go │ │ │ │ └── mysqllisttablefragmentation.go │ │ │ ├── mysqllisttables │ │ │ │ ├── mysqllisttables_test.go │ │ │ │ └── mysqllisttables.go │ │ │ ├── mysqllisttablesmissinguniqueindexes │ │ │ │ ├── mysqllisttablesmissinguniqueindexes_test.go │ │ │ │ └── mysqllisttablesmissinguniqueindexes.go │ │ │ └── mysqlsql │ │ │ ├── mysqlsql_test.go │ │ │ └── mysqlsql.go │ │ ├── neo4j │ │ │ ├── neo4jcypher │ │ │ │ ├── neo4jcypher_test.go │ │ │ │ └── neo4jcypher.go │ │ │ ├── neo4jexecutecypher │ │ │ │ ├── classifier │ │ │ │ │ ├── classifier_test.go │ │ │ │ │ └── classifier.go │ │ │ │ ├── neo4jexecutecypher_test.go │ │ │ │ └── neo4jexecutecypher.go │ │ │ └── neo4jschema │ │ │ ├── cache │ │ │ │ ├── cache_test.go │ │ │ │ └── cache.go │ │ │ ├── helpers │ │ │ │ ├── helpers_test.go │ │ │ │ └── helpers.go │ │ │ ├── neo4jschema_test.go │ │ │ ├── neo4jschema.go │ │ │ └── types │ │ │ └── types.go │ │ ├── oceanbase │ │ │ ├── oceanbaseexecutesql │ │ │ │ ├── oceanbaseexecutesql_test.go │ │ │ │ └── oceanbaseexecutesql.go │ │ │ └── oceanbasesql │ │ │ ├── oceanbasesql_test.go │ │ │ └── oceanbasesql.go │ │ ├── oracle │ │ │ ├── oracleexecutesql │ │ │ │ └── oracleexecutesql.go │ │ │ └── oraclesql │ │ │ └── oraclesql.go │ │ ├── parameters_test.go │ │ ├── parameters.go │ │ ├── postgres │ │ │ ├── postgresexecutesql │ │ │ │ ├── postgresexecutesql_test.go │ │ │ │ └── postgresexecutesql.go │ │ │ ├── postgreslistactivequeries │ │ │ │ ├── postgreslistactivequeries_test.go │ │ │ │ └── postgreslistactivequeries.go │ │ │ ├── postgreslistavailableextensions │ │ │ │ ├── postgreslistavailableextensions_test.go │ │ │ │ └── postgreslistavailableextensions.go │ │ │ ├── postgreslistinstalledextensions │ │ │ │ ├── postgreslistinstalledextensions_test.go │ │ │ │ └── postgreslistinstalledextensions.go │ │ │ ├── postgreslisttables │ │ │ │ ├── postgreslisttables_test.go │ │ │ │ └── postgreslisttables.go │ │ │ └── postgressql │ │ │ ├── postgressql_test.go │ │ │ └── postgressql.go │ │ ├── redis │ │ │ ├── redis_test.go │ │ │ └── redis.go │ │ ├── spanner │ │ │ ├── spannerexecutesql │ │ │ │ ├── spannerexecutesql_test.go │ │ │ │ └── spannerexecutesql.go │ │ │ ├── spannerlisttables │ │ │ │ ├── spannerlisttables_test.go │ │ │ │ └── spannerlisttables.go │ │ │ └── spannersql │ │ │ ├── spanner_test.go │ │ │ └── spannersql.go │ │ ├── sqlite │ │ │ ├── sqliteexecutesql │ │ │ │ ├── sqliteexecutesql_test.go │ │ │ │ └── sqliteexecutesql.go │ │ │ └── sqlitesql │ │ │ ├── sqlitesql_test.go │ │ │ └── sqlitesql.go │ │ ├── tidb │ │ │ ├── tidbexecutesql │ │ │ │ ├── tidbexecutesql_test.go │ │ │ │ └── tidbexecutesql.go │ │ │ └── tidbsql │ │ │ ├── tidbsql_test.go │ │ │ └── tidbsql.go │ │ ├── tools_test.go │ │ ├── tools.go │ │ ├── toolsets.go │ │ ├── trino │ │ │ ├── trinoexecutesql │ │ │ │ ├── trinoexecutesql_test.go │ │ │ │ └── trinoexecutesql.go │ │ │ └── trinosql │ │ │ ├── trinosql_test.go │ │ │ └── trinosql.go │ │ ├── utility │ │ │ └── wait │ │ │ ├── wait_test.go │ │ │ └── wait.go │ │ ├── valkey │ │ │ ├── valkey_test.go │ │ │ └── valkey.go │ │ └── yugabytedbsql │ │ ├── yugabytedbsql_test.go │ │ └── yugabytedbsql.go │ └── util │ └── util.go ├── LICENSE ├── logo.png ├── main.go ├── MCP-TOOLBOX-EXTENSION.md ├── README.md └── tests ├── alloydb │ ├── alloydb_integration_test.go │ └── alloydb_wait_for_operation_test.go ├── alloydbainl │ └── alloydb_ai_nl_integration_test.go ├── alloydbpg │ └── alloydb_pg_integration_test.go ├── auth.go ├── bigquery │ └── bigquery_integration_test.go ├── bigtable │ └── bigtable_integration_test.go ├── cassandra │ └── cassandra_integration_test.go ├── clickhouse │ └── clickhouse_integration_test.go ├── cloudmonitoring │ └── cloud_monitoring_integration_test.go ├── cloudsql │ ├── cloud_sql_create_database_test.go │ ├── cloud_sql_create_users_test.go │ ├── cloud_sql_get_instances_test.go │ ├── cloud_sql_list_databases_test.go │ ├── cloudsql_list_instances_test.go │ └── cloudsql_wait_for_operation_test.go ├── cloudsqlmssql │ ├── cloud_sql_mssql_create_instance_integration_test.go │ └── cloud_sql_mssql_integration_test.go ├── cloudsqlmysql │ ├── cloud_sql_mysql_create_instance_integration_test.go │ └── cloud_sql_mysql_integration_test.go ├── cloudsqlpg │ ├── cloud_sql_pg_create_instances_test.go │ └── cloud_sql_pg_integration_test.go ├── common.go ├── couchbase │ └── couchbase_integration_test.go ├── dataform │ └── dataform_integration_test.go ├── dataplex │ └── dataplex_integration_test.go ├── dgraph │ └── dgraph_integration_test.go ├── firebird │ └── firebird_integration_test.go ├── firestore │ └── firestore_integration_test.go ├── http │ └── http_integration_test.go ├── looker │ └── looker_integration_test.go ├── mongodb │ └── mongodb_integration_test.go ├── mssql │ └── mssql_integration_test.go ├── mysql │ └── mysql_integration_test.go ├── neo4j │ └── neo4j_integration_test.go ├── oceanbase │ └── oceanbase_integration_test.go ├── option.go ├── oracle │ └── oracle_integration_test.go ├── postgres │ └── postgres_integration_test.go ├── redis │ └── redis_test.go ├── server.go ├── source.go ├── spanner │ └── spanner_integration_test.go ├── sqlite │ └── sqlite_integration_test.go ├── tidb │ └── tidb_integration_test.go ├── tool.go ├── trino │ └── trino_integration_test.go ├── utility │ └── wait_integration_test.go ├── valkey │ └── valkey_test.go └── yugabytedb └── yugabytedb_integration_test.go ``` # Files -------------------------------------------------------------------------------- /internal/tools/spanner/spannersql/spannersql.go: -------------------------------------------------------------------------------- ```go // Copyright 2024 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 spannersql import ( "context" "fmt" "strings" "cloud.google.com/go/spanner" yaml "github.com/goccy/go-yaml" "github.com/googleapis/genai-toolbox/internal/sources" spannerdb "github.com/googleapis/genai-toolbox/internal/sources/spanner" "github.com/googleapis/genai-toolbox/internal/tools" "google.golang.org/api/iterator" ) const kind string = "spanner-sql" func init() { if !tools.Register(kind, newConfig) { panic(fmt.Sprintf("tool kind %q already registered", kind)) } } func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { actual := Config{Name: name} if err := decoder.DecodeContext(ctx, &actual); err != nil { return nil, err } return actual, nil } type compatibleSource interface { SpannerClient() *spanner.Client DatabaseDialect() string } // validate compatible sources are still compatible var _ compatibleSource = &spannerdb.Source{} var compatibleSources = [...]string{spannerdb.SourceKind} 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"` Statement string `yaml:"statement" validate:"required"` ReadOnly bool `yaml:"readOnly"` AuthRequired []string `yaml:"authRequired"` Parameters tools.Parameters `yaml:"parameters"` TemplateParameters tools.Parameters `yaml:"templateParameters"` } // 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.(compatibleSource) if !ok { return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) } allParameters, paramManifest, err := tools.ProcessParameters(cfg.TemplateParameters, cfg.Parameters) if err != nil { return nil, err } mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) // finish tool setup t := Tool{ Name: cfg.Name, Kind: kind, Parameters: cfg.Parameters, TemplateParameters: cfg.TemplateParameters, AllParams: allParameters, Statement: cfg.Statement, AuthRequired: cfg.AuthRequired, ReadOnly: cfg.ReadOnly, Client: s.SpannerClient(), dialect: s.DatabaseDialect(), manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, mcpManifest: mcpManifest, } return t, nil } // validate interface var _ tools.Tool = Tool{} type Tool struct { Name string `yaml:"name"` Kind string `yaml:"kind"` AuthRequired []string `yaml:"authRequired"` Parameters tools.Parameters `yaml:"parameters"` TemplateParameters tools.Parameters `yaml:"templateParameters"` AllParams tools.Parameters `yaml:"allParams"` ReadOnly bool `yaml:"readOnly"` Client *spanner.Client dialect string Statement string manifest tools.Manifest mcpManifest tools.McpManifest } func getMapParams(params tools.ParamValues, dialect string) (map[string]interface{}, error) { switch strings.ToLower(dialect) { case "googlesql": return params.AsMap(), nil case "postgresql": return params.AsMapByOrderedKeys(), nil default: return nil, fmt.Errorf("invalid dialect %s", dialect) } } // 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 defer iter.Stop() for { row, err := iter.Next() if err == iterator.Done { break } if err != nil { return nil, fmt.Errorf("unable to parse row: %w", err) } vMap := make(map[string]any) cols := row.ColumnNames() for i, c := range cols { vMap[c] = row.ColumnValue(i) } out = append(out, vMap) } return out, nil } func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { paramsMap := params.AsMap() newStatement, err := tools.ResolveTemplateParams(t.TemplateParameters, t.Statement, paramsMap) if err != nil { return nil, fmt.Errorf("unable to extract template params %w", err) } newParams, err := tools.GetParams(t.Parameters, paramsMap) if err != nil { return nil, fmt.Errorf("unable to extract standard params %w", err) } for i, p := range t.Parameters { name := p.GetName() value := newParams[i].Value // Spanner only accepts typed slices as input // This checks if the param is an array. // If yes, convert []any to typed slice (e.g []string, []int) switch arrayParam := p.(type) { case *tools.ArrayParameter: arrayParamValue, ok := value.([]any) if !ok { return nil, fmt.Errorf("unable to convert parameter `%s` to []any %w", name, err) } itemType := arrayParam.GetItems().GetType() var err error value, err = tools.ConvertAnySliceToTyped(arrayParamValue, itemType) if err != nil { return nil, fmt.Errorf("unable to convert parameter `%s` from []any to typed slice: %w", name, err) } } newParams[i] = tools.ParamValue{Name: name, Value: value} } mapParams, err := getMapParams(newParams, t.dialect) if err != nil { return nil, fmt.Errorf("fail to get map params: %w", err) } var results []any var opErr error stmt := spanner.Statement{ SQL: newStatement, Params: mapParams, } if t.ReadOnly { iter := t.Client.Single().Query(ctx, stmt) results, opErr = processRows(iter) } else { _, opErr = t.Client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { iter := txn.Query(ctx, stmt) results, err = processRows(iter) if err != nil { return err } return nil }) } if opErr != nil { return nil, fmt.Errorf("unable to execute client: %w", opErr) } return results, nil } func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { return tools.ParseParams(t.AllParams, 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 false } ``` -------------------------------------------------------------------------------- /internal/tools/mysql/mysqllisttablefragmentation/mysqllisttablefragmentation.go: -------------------------------------------------------------------------------- ```go // 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 mysqllisttablefragmentation import ( "context" "database/sql" "fmt" yaml "github.com/goccy/go-yaml" "github.com/googleapis/genai-toolbox/internal/sources" "github.com/googleapis/genai-toolbox/internal/sources/cloudsqlmysql" "github.com/googleapis/genai-toolbox/internal/sources/mysql" "github.com/googleapis/genai-toolbox/internal/tools" "github.com/googleapis/genai-toolbox/internal/tools/mysql/mysqlcommon" "github.com/googleapis/genai-toolbox/internal/util" ) const kind string = "mysql-list-table-fragmentation" const listTableFragmentationStatement = ` SELECT table_schema, table_name, data_length AS data_size, index_length AS index_size, data_free AS data_free, ROUND((data_free / (data_length + index_length)) * 100, 2) AS fragmentation_percentage FROM information_schema.tables WHERE table_schema NOT IN ('sys', 'performance_schema', 'mysql', 'information_schema') AND (COALESCE(?, '') = '' OR table_schema = ?) AND (COALESCE(?, '') = '' OR table_name = ?) AND data_free >= ? ORDER BY fragmentation_percentage DESC, table_schema, table_name LIMIT ?; ` func init() { if !tools.Register(kind, newConfig) { panic(fmt.Sprintf("tool kind %q already registered", kind)) } } func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { actual := Config{Name: name} if err := decoder.DecodeContext(ctx, &actual); err != nil { return nil, err } return actual, nil } type compatibleSource interface { MySQLPool() *sql.DB } // validate compatible sources are still compatible var _ compatibleSource = &mysql.Source{} var _ compatibleSource = &cloudsqlmysql.Source{} var compatibleSources = [...]string{mysql.SourceKind, cloudsqlmysql.SourceKind} 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.(compatibleSource) if !ok { return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) } allParameters := tools.Parameters{ tools.NewStringParameterWithDefault("table_schema", "", "(Optional) The database where fragmentation check is to be executed. Check all tables visible to the current user if not specified"), tools.NewStringParameterWithDefault("table_name", "", "(Optional) Name of the table to be checked. Check all tables visible to the current user if not specified."), tools.NewIntParameterWithDefault("data_free_threshold_bytes", 1, "(Optional) Only show tables with at least this much free space in bytes. Default is 1"), tools.NewIntParameterWithDefault("limit", 10, "(Optional) Max rows to return, default is 10"), } mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) // finish tool setup t := Tool{ Name: cfg.Name, Kind: kind, AuthRequired: cfg.AuthRequired, Pool: s.MySQLPool(), allParams: allParameters, manifest: tools.Manifest{Description: cfg.Description, Parameters: allParameters.Manifest(), AuthRequired: cfg.AuthRequired}, mcpManifest: mcpManifest, } return t, nil } // validate interface var _ tools.Tool = Tool{} type Tool struct { Name string `yaml:"name"` Kind string `yaml:"kind"` AuthRequired []string `yaml:"authRequired"` allParams tools.Parameters `yaml:"parameters"` Pool *sql.DB manifest tools.Manifest mcpManifest tools.McpManifest } func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { paramsMap := params.AsMap() table_schema, ok := paramsMap["table_schema"].(string) if !ok { return nil, fmt.Errorf("invalid 'table_schema' parameter; expected a string") } table_name, ok := paramsMap["table_name"].(string) if !ok { return nil, fmt.Errorf("invalid 'table_name' parameter; expected a string") } data_free_threshold_bytes, ok := paramsMap["data_free_threshold_bytes"].(int) if !ok { return nil, fmt.Errorf("invalid 'data_free_threshold_bytes' parameter; expected an integer") } limit, ok := paramsMap["limit"].(int) if !ok { return nil, fmt.Errorf("invalid 'limit' parameter; expected an integer") } // Log the query executed for debugging. logger, err := util.LoggerFromContext(ctx) if err != nil { return nil, fmt.Errorf("error getting logger: %s", err) } logger.DebugContext(ctx, "executing `%s` tool query: %s", kind, listTableFragmentationStatement) results, err := t.Pool.QueryContext(ctx, listTableFragmentationStatement, table_schema, table_schema, table_name, table_name, data_free_threshold_bytes, limit) if err != nil { return nil, fmt.Errorf("unable to execute query: %w", err) } defer results.Close() cols, err := results.Columns() if err != nil { return nil, fmt.Errorf("unable to retrieve rows column name: %w", err) } // create an array of values for each column, which can be re-used to scan each row rawValues := make([]any, len(cols)) values := make([]any, len(cols)) for i := range rawValues { values[i] = &rawValues[i] } colTypes, err := results.ColumnTypes() if err != nil { return nil, fmt.Errorf("unable to get column types: %w", err) } var out []any for results.Next() { err := results.Scan(values...) if err != nil { return nil, fmt.Errorf("unable to parse row: %w", err) } vMap := make(map[string]any) for i, name := range cols { val := rawValues[i] if val == nil { vMap[name] = nil continue } vMap[name], err = mysqlcommon.ConvertToType(colTypes[i], val) if err != nil { return nil, fmt.Errorf("errors encountered when converting values: %w", err) } } out = append(out, vMap) } if err := results.Err(); err != nil { return nil, fmt.Errorf("errors encountered during row iteration: %w", err) } return out, nil } func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { return tools.ParseParams(t.allParams, 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 false } ``` -------------------------------------------------------------------------------- /internal/server/static/js/loadTools.js: -------------------------------------------------------------------------------- ```javascript // 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. import { renderToolInterface } from "./toolDisplay.js"; let toolDetailsAbortController = null; /** * Fetches a toolset from the /api/toolset endpoint and initiates creating the tool list. * @param {!HTMLElement} secondNavContent The HTML element where the tool list will be rendered. * @param {!HTMLElement} toolDisplayArea The HTML element where the details of a selected tool will be displayed. * @param {string} toolsetName The name of the toolset to load (empty string loads all tools). * @returns {!Promise<void>} A promise that resolves when the tools are loaded and rendered, or rejects on error. */ export async function loadTools(secondNavContent, toolDisplayArea, toolsetName) { secondNavContent.innerHTML = '<p>Fetching tools...</p>'; try { const response = await fetch(`/api/toolset/${toolsetName}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const apiResponse = await response.json(); renderToolList(apiResponse, secondNavContent, toolDisplayArea); } catch (error) { console.error('Failed to load tools:', error); secondNavContent.innerHTML = `<p class="error">Failed to load tools: <pre><code>${error}</code></pre></p>`; } } /** * Renders the list of tools as buttons within the provided HTML element. * @param {?{tools: ?Object<string,*>} } apiResponse The API response object containing the tools. * @param {!HTMLElement} secondNavContent The HTML element to render the tool list into. * @param {!HTMLElement} toolDisplayArea The HTML element for displaying tool details (passed to event handlers). */ function renderToolList(apiResponse, secondNavContent, toolDisplayArea) { secondNavContent.innerHTML = ''; if (!apiResponse || typeof apiResponse.tools !== 'object' || apiResponse.tools === null) { console.error('Error: Expected an object with a "tools" property, but received:', apiResponse); secondNavContent.textContent = 'Error: Invalid response format from toolset API.'; return; } const toolsObject = apiResponse.tools; const toolNames = Object.keys(toolsObject); if (toolNames.length === 0) { secondNavContent.textContent = 'No tools found.'; return; } const ul = document.createElement('ul'); toolNames.forEach(toolName => { const li = document.createElement('li'); const button = document.createElement('button'); button.textContent = toolName; button.dataset.toolname = toolName; button.classList.add('tool-button'); button.addEventListener('click', (event) => handleToolClick(event, secondNavContent, toolDisplayArea)); li.appendChild(button); ul.appendChild(li); }); secondNavContent.appendChild(ul); } /** * Handles the click event on a tool button. * @param {!Event} event The click event object. * @param {!HTMLElement} secondNavContent The parent element containing the tool buttons. * @param {!HTMLElement} toolDisplayArea The HTML element where tool details will be shown. */ function handleToolClick(event, secondNavContent, toolDisplayArea) { const toolName = event.target.dataset.toolname; if (toolName) { const currentActive = secondNavContent.querySelector('.tool-button.active'); if (currentActive) { currentActive.classList.remove('active'); } event.target.classList.add('active'); fetchToolDetails(toolName, toolDisplayArea); } } /** * Fetches details for a specific tool /api/tool endpoint. * It aborts any previous in-flight request for tool details to stop race condition. * @param {string} toolName The name of the tool to fetch details for. * @param {!HTMLElement} toolDisplayArea The HTML element to display the tool interface in. * @returns {!Promise<void>} A promise that resolves when the tool details are fetched and rendered, or rejects on error. */ async function fetchToolDetails(toolName, toolDisplayArea) { if (toolDetailsAbortController) { toolDetailsAbortController.abort(); console.debug("Aborted previous tool fetch."); } toolDetailsAbortController = new AbortController(); const signal = toolDetailsAbortController.signal; toolDisplayArea.innerHTML = '<p>Loading tool details...</p>'; try { const response = await fetch(`/api/tool/${encodeURIComponent(toolName)}`, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const apiResponse = await response.json(); if (!apiResponse.tools || !apiResponse.tools[toolName]) { throw new Error(`Tool "${toolName}" data not found in API response.`); } const toolObject = apiResponse.tools[toolName]; console.debug("Received tool object: ", toolObject) const toolInterfaceData = { id: toolName, name: toolName, description: toolObject.description || "No description provided.", authRequired: toolObject.authRequired || [], parameters: (toolObject.parameters || []).map(param => { let inputType = 'text'; const apiType = param.type ? param.type.toLowerCase() : 'string'; let valueType = 'string'; let label = param.description || param.name; if (apiType === 'integer' || apiType === 'float') { inputType = 'number'; valueType = 'number'; } else if (apiType === 'boolean') { inputType = 'checkbox'; valueType = 'boolean'; } else if (apiType === 'array') { inputType = 'textarea'; const itemType = param.items && param.items.type ? param.items.type.toLowerCase() : 'string'; valueType = `array<${itemType}>`; label += ' (Array)'; } return { name: param.name, type: inputType, valueType: valueType, label: label, authServices: param.authSources, required: param.required || false, // defaultValue: param.default, can't do this yet bc tool manifest doesn't have default }; }) }; console.debug("Transformed toolInterfaceData:", toolInterfaceData); renderToolInterface(toolInterfaceData, toolDisplayArea); } catch (error) { if (error.name === 'AbortError') { console.debug("Previous fetch was aborted, expected behavior."); } else { console.error(`Failed to load details for tool "${toolName}":`, error); toolDisplayArea.innerHTML = `<p class="error">Failed to load details for ${toolName}. ${error.message}</p>`; } } } ``` -------------------------------------------------------------------------------- /internal/sources/yugabytedb/yugabytedb_test.go: -------------------------------------------------------------------------------- ```go // 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 yugabytedb_test import ( "testing" "strings" 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/sources/yugabytedb" "github.com/googleapis/genai-toolbox/internal/testutils" ) // Basic config parse func TestParseFromYamlYugabyteDB(t *testing.T) { tcs := []struct { desc string in string want server.SourceConfigs }{ { desc: "only required fields", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", }, }, }, { desc: "with loadBalance only", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db loadBalance: true `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", LoadBalance: "true", }, }, }, { desc: "loadBalance with topologyKeys", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db loadBalance: true topologyKeys: zone1,zone2 `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", LoadBalance: "true", TopologyKeys: "zone1,zone2", }, }, }, { desc: "with fallback only", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db loadBalance: true topologyKeys: zone1 fallbackToTopologyKeysOnly: true `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", LoadBalance: "true", TopologyKeys: "zone1", FallBackToTopologyKeysOnly: "true", }, }, }, { desc: "with refresh interval and reconnect delay", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db loadBalance: true ybServersRefreshInterval: 20 failedHostReconnectDelaySecs: 5 `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", LoadBalance: "true", YBServersRefreshInterval: "20", FailedHostReconnectDelaySeconds: "5", }, }, }, { desc: "all fields set", in: ` sources: my-yb-instance: kind: yugabytedb name: my-yb-instance host: yb-host port: yb-port user: yb_user password: yb_pass database: yb_db loadBalance: true topologyKeys: zone1,zone2 fallbackToTopologyKeysOnly: true ybServersRefreshInterval: 30 failedHostReconnectDelaySecs: 10 `, want: server.SourceConfigs{ "my-yb-instance": yugabytedb.Config{ Name: "my-yb-instance", Kind: "yugabytedb", Host: "yb-host", Port: "yb-port", User: "yb_user", Password: "yb_pass", Database: "yb_db", LoadBalance: "true", TopologyKeys: "zone1,zone2", FallBackToTopologyKeysOnly: "true", YBServersRefreshInterval: "30", FailedHostReconnectDelaySeconds: "10", }, }, }, } for _, tc := range tcs { t.Run(tc.desc, func(t *testing.T) { got := struct { Sources server.SourceConfigs `yaml:"sources"` }{} err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) if err != nil { t.Fatalf("unable to unmarshal: %s", err) } if !cmp.Equal(tc.want, got.Sources) { t.Fatalf("incorrect parse (-want +got):\n%s", cmp.Diff(tc.want, got.Sources)) } }) } } func TestFailParseFromYamlYugabyteDB(t *testing.T) { tcs := []struct { desc string in string err string }{ { desc: "extra field", in: ` sources: my-yb-source: kind: yugabytedb name: my-yb-source host: yb-host port: yb-port database: yb_db user: yb_user password: yb_pass foo: bar `, err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": [2:1] unknown field \"foo\"", }, { desc: "missing required field (password)", in: ` sources: my-yb-source: kind: yugabytedb name: my-yb-source host: yb-host port: yb-port database: yb_db user: yb_user `, err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Password' Error:Field validation for 'Password' failed on the 'required' tag", }, { desc: "missing required field (host)", in: ` sources: my-yb-source: kind: yugabytedb name: my-yb-source port: yb-port database: yb_db user: yb_user password: yb_pass `, err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Host' Error:Field validation for 'Host' failed on the 'required' tag", }, } for _, tc := range tcs { t.Run(tc.desc, func(t *testing.T) { got := struct { Sources server.SourceConfigs `yaml:"sources"` }{} err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) if err == nil { t.Fatalf("expected parsing to fail") } errStr := err.Error() if !strings.Contains(errStr, tc.err) { t.Fatalf("unexpected error:\nGot: %q\nWant: %q", errStr, tc.err) } }) } } ``` -------------------------------------------------------------------------------- /internal/tools/mongodb/mongodbfind/mongodbfind.go: -------------------------------------------------------------------------------- ```go // 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 mongodbfind import ( "context" "encoding/json" "fmt" "slices" "github.com/goccy/go-yaml" mongosrc "github.com/googleapis/genai-toolbox/internal/sources/mongodb" "github.com/googleapis/genai-toolbox/internal/util" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "github.com/googleapis/genai-toolbox/internal/sources" "github.com/googleapis/genai-toolbox/internal/tools" ) const kind string = "mongodb-find" 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"` AuthRequired []string `yaml:"authRequired" validate:"required"` Description string `yaml:"description" validate:"required"` Database string `yaml:"database" validate:"required"` Collection string `yaml:"collection" validate:"required"` FilterPayload string `yaml:"filterPayload" validate:"required"` FilterParams tools.Parameters `yaml:"filterParams"` ProjectPayload string `yaml:"projectPayload"` ProjectParams tools.Parameters `yaml:"projectParams"` SortPayload string `yaml:"sortPayload"` SortParams tools.Parameters `yaml:"sortParams"` Limit int64 `yaml:"limit"` } // 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.(*mongosrc.Source) if !ok { return nil, fmt.Errorf("invalid source for %q tool: source kind must be `mongodb`", kind) } // Create a slice for all parameters allParameters := slices.Concat(cfg.FilterParams, cfg.ProjectParams, cfg.SortParams) // Verify no duplicate parameter names err := tools.CheckDuplicateParameters(allParameters) if err != nil { return nil, err } // Verify 'limit' value if cfg.Limit <= 0 { return nil, fmt.Errorf("limit must be a positive number, but got %d", cfg.Limit) } // Create Toolbox manifest paramManifest := allParameters.Manifest() if paramManifest == nil { paramManifest = make([]tools.ParameterManifest, 0) } // Create MCP manifest mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) // finish tool setup return Tool{ Name: cfg.Name, Kind: kind, AuthRequired: cfg.AuthRequired, Collection: cfg.Collection, FilterPayload: cfg.FilterPayload, FilterParams: cfg.FilterParams, ProjectPayload: cfg.ProjectPayload, ProjectParams: cfg.ProjectParams, SortPayload: cfg.SortPayload, SortParams: cfg.SortParams, Limit: cfg.Limit, AllParams: allParameters, database: s.Client.Database(cfg.Database), manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, mcpManifest: mcpManifest, }, nil } // validate interface var _ tools.Tool = Tool{} type Tool struct { Name string `yaml:"name"` Kind string `yaml:"kind"` Description string `yaml:"description"` AuthRequired []string `yaml:"authRequired"` Collection string `yaml:"collection"` FilterPayload string `yaml:"filterPayload"` FilterParams tools.Parameters `yaml:"filterParams"` ProjectPayload string `yaml:"projectPayload"` ProjectParams tools.Parameters `yaml:"projectParams"` SortPayload string `yaml:"sortPayload"` SortParams tools.Parameters `yaml:"sortParams"` Limit int64 `yaml:"limit"` AllParams tools.Parameters `yaml:"allParams"` database *mongo.Database manifest tools.Manifest mcpManifest tools.McpManifest } func getOptions(ctx context.Context, sortParameters tools.Parameters, projectPayload string, limit int64, paramsMap map[string]any) (*options.FindOptions, error) { logger, err := util.LoggerFromContext(ctx) if err != nil { panic(err) } opts := options.Find() sort := bson.M{} for _, p := range sortParameters { sort[p.GetName()] = paramsMap[p.GetName()] } opts = opts.SetSort(sort) if len(projectPayload) > 0 { result, err := tools.PopulateTemplateWithJSON("MongoDBFindProjectString", projectPayload, paramsMap) if err != nil { return nil, fmt.Errorf("error populating project payload: %s", err) } var projection any err = bson.UnmarshalExtJSON([]byte(result), false, &projection) if err != nil { return nil, fmt.Errorf("error unmarshalling projection: %s", err) } opts = opts.SetProjection(projection) logger.DebugContext(ctx, "Projection is set to %v", projection) } if limit > 0 { opts = opts.SetLimit(limit) logger.DebugContext(ctx, "Limit is being set to %d", limit) } return opts, nil } func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { paramsMap := params.AsMap() filterString, err := tools.PopulateTemplateWithJSON("MongoDBFindFilterString", t.FilterPayload, paramsMap) if err != nil { return nil, fmt.Errorf("error populating filter: %s", err) } opts, err := getOptions(ctx, t.SortParams, t.ProjectPayload, t.Limit, paramsMap) if err != nil { return nil, fmt.Errorf("error populating options: %s", err) } var filter = bson.D{} err = bson.UnmarshalExtJSON([]byte(filterString), false, &filter) if err != nil { return nil, err } cur, err := t.database.Collection(t.Collection).Find(ctx, filter, opts) if err != nil { return nil, err } defer cur.Close(ctx) var data = []any{} err = cur.All(context.TODO(), &data) if err != nil { return nil, err } var final []any for _, item := range data { tmp, _ := bson.MarshalExtJSON(item, false, false) var tmp2 any err = json.Unmarshal(tmp, &tmp2) if err != nil { return nil, err } final = append(final, tmp2) } return final, err } func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { return tools.ParseParams(t.AllParams, 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 false } ``` -------------------------------------------------------------------------------- /internal/server/static/js/auth.js: -------------------------------------------------------------------------------- ```javascript // 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. /** * Renders the Google Sign-In button using the GIS library. * @param {string} toolId The ID of the tool. * @param {string} clientId The Google OAuth Client ID. * @param {string} authProfileName The name of the auth service in tools file. */ function renderGoogleSignInButton(toolId, clientId, authProfileName) { const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; const GIS_CONTAINER_ID = `gisContainer-${UNIQUE_ID_BASE}`; const gisContainer = document.getElementById(GIS_CONTAINER_ID); const setupGisBtn = document.querySelector(`#google-auth-details-${UNIQUE_ID_BASE} .btn--setup-gis`); if (!gisContainer) { console.error('GIS container not found:', GIS_CONTAINER_ID); return; } if (!clientId) { alert('Please enter an OAuth Client ID first.'); return; } // hide the setup button and show the container for the GIS button if (setupGisBtn) setupGisBtn.style.display = 'none'; gisContainer.innerHTML = ''; gisContainer.style.display = 'flex'; if (window.google && window.google.accounts && window.google.accounts.id) { try { const handleResponse = (response) => handleCredentialResponse(response, toolId, authProfileName); window.google.accounts.id.initialize({ client_id: clientId, callback: handleResponse, auto_select: false }); window.google.accounts.id.renderButton( gisContainer, { theme: "outline", size: "large", text: "signin_with" } ); } catch (error) { console.error("Error initializing Google Sign-In:", error); alert("Error initializing Google Sign-In. Check the Client ID and browser console."); gisContainer.innerHTML = '<p style="color: red;">Error loading Sign-In button.</p>'; if (setupGisBtn) setupGisBtn.style.display = ''; } } else { console.error("GIS library not fully loaded yet."); alert("Google Identity Services library not ready. Please try again in a moment."); gisContainer.innerHTML = '<p style="color: red;">GIS library not ready.</p>'; if (setupGisBtn) setupGisBtn.style.display = ''; } } /** * Handles the credential response from the Google Sign-In library. * @param {!CredentialResponse} response The credential response object from GIS. * @param {string} toolId The ID of the tool. * @param {string} authProfileName The name of the auth service in tools file. */ function handleCredentialResponse(response, toolId, authProfileName) { console.debug("handleCredentialResponse called with:", { response, toolId, authProfileName }); const headersTextarea = document.getElementById(`headers-textarea-${toolId}`); if (!headersTextarea) { console.error('Headers textarea not found for toolId:', toolId); return; } const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; const setupGisBtn = document.querySelector(`#google-auth-details-${UNIQUE_ID_BASE} .setup-gis-btn`); const gisContainer = document.getElementById(`gisContainer-${UNIQUE_ID_BASE}`); if (response.credential) { const idToken = response.credential; try { let currentHeaders = {}; if (headersTextarea.value) { currentHeaders = JSON.parse(headersTextarea.value); } const HEADER_KEY = `${authProfileName}_token`; currentHeaders[HEADER_KEY] = `${idToken}`; headersTextarea.value = JSON.stringify(currentHeaders, null, 2); if (gisContainer) gisContainer.style.display = 'none'; if (setupGisBtn) setupGisBtn.style.display = ''; } catch (e) { alert('Headers are not valid JSON. Please correct and try again.'); console.error("Header JSON parse error:", e); } } else { console.error("Error: No credential in response", response); alert('Error: No ID Token received. Check console for details.'); if (gisContainer) gisContainer.style.display = 'none'; if (setupGisBtn) setupGisBtn.style.display = ''; } } // creates the Google Auth method dropdown export function createGoogleAuthMethodItem(toolId, authProfileName) { const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; const item = document.createElement('div'); item.className = 'auth-method-item'; item.innerHTML = ` <div class="auth-method-header"> <span class="auth-method-label">Google ID Token (${authProfileName})</span> <button class="toggle-details-tab">Auto Setup</button> </div> <div class="auth-method-details" id="google-auth-details-${UNIQUE_ID_BASE}" style="display: none;"> <div class="auth-controls"> <div class="auth-input-row"> <label for="clientIdInput-${UNIQUE_ID_BASE}">OAuth Client ID:</label> <input type="text" id="clientIdInput-${UNIQUE_ID_BASE}" placeholder="Enter Client ID" class="auth-input"> </div> <div class="auth-instructions"> <strong>Action Required:</strong> Add this URL (e.g., http://localhost:PORT) to the Client ID's <strong>Authorized JavaScript origins</strong> to avoid a 401 error. If using Google OAuth, navigate to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> for this setting. </div> <div class="auth-method-actions"> <button class="btn btn--setup-gis">Continue</button> <div id="gisContainer-${UNIQUE_ID_BASE}" class="auth-interactive-element gis-container" style="display: none;"></div> </div> </div> </div> `; const toggleBtn = item.querySelector('.toggle-details-tab'); const detailsDiv = item.querySelector(`#google-auth-details-${UNIQUE_ID_BASE}`); const setupGisBtn = item.querySelector('.btn--setup-gis'); const clientIdInput = item.querySelector(`#clientIdInput-${UNIQUE_ID_BASE}`); const gisContainer = item.querySelector(`#gisContainer-${UNIQUE_ID_BASE}`); toggleBtn.addEventListener('click', () => { const isVisible = detailsDiv.style.display === 'flex'; detailsDiv.style.display = isVisible ? 'none' : 'flex'; toggleBtn.textContent = isVisible ? 'Auto Setup' : 'Close'; if (!isVisible) { if (gisContainer) { gisContainer.innerHTML = ''; gisContainer.style.display = 'none'; } if (setupGisBtn) { setupGisBtn.style.display = ''; } } }); setupGisBtn.addEventListener('click', () => { const clientId = clientIdInput.value; if (!clientId) { alert('Please enter an OAuth Client ID first.'); return; } renderGoogleSignInButton(toolId, clientId, authProfileName); }); return item; } ``` -------------------------------------------------------------------------------- /internal/sources/clickhouse/clickhouse_test.go: -------------------------------------------------------------------------------- ```go // 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 clickhouse import ( "context" "strings" "testing" "github.com/goccy/go-yaml" "github.com/google/go-cmp/cmp" "github.com/googleapis/genai-toolbox/internal/testutils" "go.opentelemetry.io/otel" ) func TestConfigSourceConfigKind(t *testing.T) { config := Config{} if config.SourceConfigKind() != SourceKind { t.Errorf("Expected %s, got %s", SourceKind, config.SourceConfigKind()) } } func TestNewConfig(t *testing.T) { tests := []struct { name string yaml string expected Config }{ { name: "all fields specified", yaml: ` name: test-clickhouse kind: clickhouse host: localhost port: "8443" user: default password: "mypass" database: mydb protocol: https secure: true `, expected: Config{ Name: "test-clickhouse", Kind: "clickhouse", Host: "localhost", Port: "8443", User: "default", Password: "mypass", Database: "mydb", Protocol: "https", Secure: true, }, }, { name: "minimal configuration with defaults", yaml: ` name: minimal-clickhouse kind: clickhouse host: 127.0.0.1 port: "8123" user: testuser database: testdb `, expected: Config{ Name: "minimal-clickhouse", Kind: "clickhouse", Host: "127.0.0.1", Port: "8123", User: "testuser", Password: "", Database: "testdb", Protocol: "", Secure: false, }, }, { name: "http protocol", yaml: ` name: http-clickhouse kind: clickhouse host: clickhouse.example.com port: "8123" user: analytics password: "securepass" database: analytics_db protocol: http secure: false `, expected: Config{ Name: "http-clickhouse", Kind: "clickhouse", Host: "clickhouse.example.com", Port: "8123", User: "analytics", Password: "securepass", Database: "analytics_db", Protocol: "http", Secure: false, }, }, { name: "https with secure connection", yaml: ` name: secure-clickhouse kind: clickhouse host: secure.clickhouse.io port: "8443" user: secureuser password: "verysecure" database: production protocol: https secure: true `, expected: Config{ Name: "secure-clickhouse", Kind: "clickhouse", Host: "secure.clickhouse.io", Port: "8443", User: "secureuser", Password: "verysecure", Database: "production", Protocol: "https", Secure: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { decoder := yaml.NewDecoder(strings.NewReader(string(testutils.FormatYaml(tt.yaml)))) config, err := newConfig(context.Background(), tt.expected.Name, decoder) if err != nil { t.Fatalf("Failed to create config: %v", err) } clickhouseConfig, ok := config.(Config) if !ok { t.Fatalf("Expected Config type, got %T", config) } if diff := cmp.Diff(tt.expected, clickhouseConfig); diff != "" { t.Errorf("Config mismatch (-want +got):\n%s", diff) } }) } } func TestNewConfigInvalidYAML(t *testing.T) { tests := []struct { name string yaml string expectError bool }{ { name: "invalid yaml syntax", yaml: ` name: test-clickhouse kind: clickhouse host: [invalid `, expectError: true, }, { name: "missing required fields", yaml: ` name: test-clickhouse kind: clickhouse `, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { decoder := yaml.NewDecoder(strings.NewReader(string(testutils.FormatYaml(tt.yaml)))) _, err := newConfig(context.Background(), "test-clickhouse", decoder) if tt.expectError && err == nil { t.Errorf("Expected error but got none") } if !tt.expectError && err != nil { t.Errorf("Expected no error but got: %v", err) } }) } } func TestSource_SourceKind(t *testing.T) { source := &Source{} if source.SourceKind() != SourceKind { t.Errorf("Expected %s, got %s", SourceKind, source.SourceKind()) } } func TestValidateConfig(t *testing.T) { tests := []struct { name string protocol string expectError bool }{ { name: "valid https protocol", protocol: "https", expectError: false, }, { name: "valid http protocol", protocol: "http", expectError: false, }, { name: "invalid protocol", protocol: "invalid", expectError: true, }, { name: "invalid protocol - native not supported", protocol: "native", expectError: true, }, { name: "empty values use defaults", protocol: "", expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateConfig(tt.protocol) if tt.expectError && err == nil { t.Errorf("Expected error but got none") } if !tt.expectError && err != nil { t.Errorf("Expected no error but got: %v", err) } }) } } func TestInitClickHouseConnectionPoolDSNGeneration(t *testing.T) { tracer := otel.Tracer("test") ctx := context.Background() tests := []struct { name string host string port string user string pass string dbname string protocol string secure bool shouldErr bool }{ { name: "http protocol with defaults", host: "localhost", port: "8123", user: "default", pass: "", dbname: "default", protocol: "http", secure: false, shouldErr: true, }, { name: "https protocol with secure", host: "localhost", port: "8443", user: "default", pass: "", dbname: "default", protocol: "https", secure: true, shouldErr: true, }, { name: "special characters in password", host: "localhost", port: "8443", user: "test@user", pass: "pass@word:with/special&chars", dbname: "default", protocol: "https", secure: true, shouldErr: true, }, { name: "invalid protocol should fail", host: "localhost", port: "9000", user: "default", pass: "", dbname: "default", protocol: "invalid", secure: false, shouldErr: true, }, { name: "empty protocol defaults to https", host: "localhost", port: "8443", user: "user", pass: "pass", dbname: "testdb", protocol: "", secure: true, shouldErr: true, }, { name: "http with secure flag should upgrade to https", host: "example.com", port: "8443", user: "user", pass: "pass", dbname: "db", protocol: "http", secure: true, shouldErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pool, err := initClickHouseConnectionPool(ctx, tracer, "test", tt.host, tt.port, tt.user, tt.pass, tt.dbname, tt.protocol, tt.secure) if !tt.shouldErr && err != nil { t.Errorf("Expected no error, got: %v", err) } if pool != nil { pool.Close() } }) } } ``` -------------------------------------------------------------------------------- /docs/en/getting-started/mcp_quickstart/_index.md: -------------------------------------------------------------------------------- ```markdown --- title: "Quickstart (MCP)" type: docs weight: 5 description: > How to get started running Toolbox locally with MCP Inspector. --- ## Overview [Model Context Protocol](https://modelcontextprotocol.io) is an open protocol that standardizes how applications provide context to LLMs. Check out this page on how to [connect to Toolbox via MCP](../../how-to/connect_via_mcp.md). ## Step 1: Set up your database In this section, we will create a database, insert some data that needs to be access 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. 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 ``` 1. Connect to your database with your new user: ```bash psql -h 127.0.0.1 -U toolbox_user -d toolbox_db ``` 1. Create a table using the following command: ```sql CREATE TABLE hotels( id INTEGER NOT NULL PRIMARY KEY, name VARCHAR NOT NULL, location VARCHAR NOT NULL, price_tier VARCHAR NOT NULL, checkin_date DATE NOT NULL, checkout_date DATE NOT NULL, booked BIT NOT NULL ); ``` 1. Insert data into the table. ```sql INSERT INTO hotels(id, name, location, price_tier, checkin_date, checkout_date, booked) VALUES (1, 'Hilton Basel', 'Basel', 'Luxury', '2024-04-22', '2024-04-20', B'0'), (2, 'Marriott Zurich', 'Zurich', 'Upscale', '2024-04-14', '2024-04-21', B'0'), (3, 'Hyatt Regency Basel', 'Basel', 'Upper Upscale', '2024-04-02', '2024-04-20', B'0'), (4, 'Radisson Blu Lucerne', 'Lucerne', 'Midscale', '2024-04-24', '2024-04-05', B'0'), (5, 'Best Western Bern', 'Bern', 'Upper Midscale', '2024-04-23', '2024-04-01', B'0'), (6, 'InterContinental Geneva', 'Geneva', 'Luxury', '2024-04-23', '2024-04-28', B'0'), (7, 'Sheraton Zurich', 'Zurich', 'Upper Upscale', '2024-04-27', '2024-04-02', B'0'), (8, 'Holiday Inn Basel', 'Basel', 'Upper Midscale', '2024-04-24', '2024-04-09', B'0'), (9, 'Courtyard Zurich', 'Zurich', 'Upscale', '2024-04-03', '2024-04-13', B'0'), (10, 'Comfort Inn Bern', 'Bern', 'Midscale', '2024-04-04', '2024-04-16', B'0'); ``` 1. End the database session: ```bash \q ``` ## Step 2: Install and configure Toolbox In this section, we will download Toolbox, configure our tools in a `tools.yaml`, and then run the Toolbox server. 1. Download the latest version of Toolbox as a binary: {{< notice tip >}} Select the [correct binary](https://github.com/googleapis/genai-toolbox/releases) corresponding to your OS and CPU architecture. {{< /notice >}} <!-- {x-release-please-start-version} --> ```bash export OS="linux/amd64" # one of linux/amd64, darwin/arm64, darwin/amd64, or windows/amd64 curl -O https://storage.googleapis.com/genai-toolbox/v0.17.0/$OS/toolbox ``` <!-- {x-release-please-end} --> 1. Make the binary executable: ```bash chmod +x toolbox ``` 1. Write the following into a `tools.yaml` file. Be sure to update any fields such as `user`, `password`, or `database` that you may have customized in the previous step. {{< notice tip >}} In practice, use environment variable replacement with the format ${ENV_NAME} instead of hardcoding your secrets into the configuration file. {{< /notice >}} ```yaml sources: my-pg-source: kind: postgres host: 127.0.0.1 port: 5432 database: toolbox_db user: toolbox_user password: my-password tools: search-hotels-by-name: kind: postgres-sql source: my-pg-source description: Search for hotels based on name. parameters: - name: name type: string description: The name of the hotel. statement: SELECT * FROM hotels WHERE name ILIKE '%' || $1 || '%'; search-hotels-by-location: kind: postgres-sql source: my-pg-source description: Search for hotels based on location. parameters: - name: location type: string description: The location of the hotel. statement: SELECT * FROM hotels WHERE location ILIKE '%' || $1 || '%'; book-hotel: kind: postgres-sql source: my-pg-source description: >- Book a hotel by its ID. If the hotel is successfully booked, returns a NULL, raises an error if not. parameters: - name: hotel_id type: string description: The ID of the hotel to book. statement: UPDATE hotels SET booked = B'1' WHERE id = $1; update-hotel: kind: postgres-sql source: my-pg-source description: >- Update a hotel's check-in and check-out dates by its ID. Returns a message indicating whether the hotel was successfully updated or not. parameters: - name: hotel_id type: string description: The ID of the hotel to update. - name: checkin_date type: string description: The new check-in date of the hotel. - name: checkout_date type: string description: The new check-out date of the hotel. statement: >- UPDATE hotels SET checkin_date = CAST($2 as date), checkout_date = CAST($3 as date) WHERE id = $1; cancel-hotel: kind: postgres-sql source: my-pg-source description: Cancel a hotel by its ID. parameters: - name: hotel_id type: string description: The ID of the hotel to cancel. statement: UPDATE hotels SET booked = B'0' WHERE id = $1; toolsets: my-toolset: - search-hotels-by-name - search-hotels-by-location - book-hotel - update-hotel - cancel-hotel ``` For more info on tools, check out the [Tools](../../resources/tools/) section. 1. Run the Toolbox server, pointing to the `tools.yaml` file created earlier: ```bash ./toolbox --tools-file "tools.yaml" ``` ## Step 3: Connect to MCP Inspector 1. Run the MCP Inspector: ```bash npx @modelcontextprotocol/inspector ``` 1. Type `y` when it asks to install the inspector package. 1. It should show the following when the MCP Inspector is up and running (please take note of `<YOUR_SESSION_TOKEN>`): ```bash Starting MCP inspector... ⚙️ Proxy server listening on localhost:6277 🔑 Session token: <YOUR_SESSION_TOKEN> Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth 🚀 MCP Inspector is up and running at: http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=<YOUR_SESSION_TOKEN> ``` 1. Open the above link in your browser. 1. For `Transport Type`, select `Streamable HTTP`. 1. For `URL`, type in `http://127.0.0.1:5000/mcp`. 1. For `Configuration` -> `Proxy Session Token`, make sure `<YOUR_SESSION_TOKEN>` is present. 1. Click Connect.  1. Select `List Tools`, you will see a list of tools configured in `tools.yaml`.  1. Test out your tools here! ``` -------------------------------------------------------------------------------- /.github/workflows/cloud_build_failure_reporter.yml: -------------------------------------------------------------------------------- ```yaml # 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. name: Cloud Build Failure Reporter on: workflow_call: inputs: trigger_names: required: true type: string workflow_dispatch: inputs: trigger_names: description: 'Cloud Build trigger names separated by comma.' required: true default: '' jobs: report: permissions: issues: 'write' checks: 'read' runs-on: 'ubuntu-latest' steps: - uses: 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' # v8 with: script: |- // parse test names const testNameSubstring = '${{ inputs.trigger_names }}'; const testNameFound = new Map(); //keeps track of whether each test is found testNameSubstring.split(',').forEach(testName => { testNameFound.set(testName, false); }); // label for all issues opened by reporter const periodicLabel = 'periodic-failure'; // check if any reporter opened any issues previously const prevIssues = await github.paginate(github.rest.issues.listForRepo, { ...context.repo, state: 'open', creator: 'github-actions[bot]', labels: [periodicLabel] }); // createOrCommentIssue creates a new issue or comments on an existing issue. const createOrCommentIssue = async function (title, txt) { if (prevIssues.length < 1) { console.log('no previous issues found, creating one'); await github.rest.issues.create({ ...context.repo, title: title, body: txt, labels: [periodicLabel] }); return; } // only comment on issue related to the current test for (const prevIssue of prevIssues) { if (prevIssue.title.includes(title)){ console.log( `found previous issue ${prevIssue.html_url}, adding comment` ); await github.rest.issues.createComment({ ...context.repo, issue_number: prevIssue.number, body: txt }); return; } } }; // updateIssues comments on any existing issues. No-op if no issue exists. const updateIssues = async function (checkName, txt) { if (prevIssues.length < 1) { console.log('no previous issues found.'); return; } // only comment on issue related to the current test for (const prevIssue of prevIssues) { if (prevIssue.title.includes(checkName)){ console.log(`found previous issue ${prevIssue.html_url}, adding comment`); await github.rest.issues.createComment({ ...context.repo, issue_number: prevIssue.number, body: txt }); } } }; // Find status of check runs. // We will find check runs for each commit and then filter for the periodic. // Checks API only allows for ref and if we use main there could be edge cases where // the check run happened on a SHA that is different from head. const commits = await github.paginate(github.rest.repos.listCommits, { ...context.repo }); const relevantChecks = new Map(); for (const commit of commits) { console.log( `checking runs at ${commit.html_url}: ${commit.commit.message}` ); const checks = await github.rest.checks.listForRef({ ...context.repo, ref: commit.sha }); // Iterate through each check and find matching names for (const check of checks.data.check_runs) { console.log(`Handling test name ${check.name}`); for (const testName of testNameFound.keys()) { if (testNameFound.get(testName) === true){ //skip if a check is already found for this name continue; } if (check.name.includes(testName)) { relevantChecks.set(check, commit); testNameFound.set(testName, true); } } } // Break out of the loop early if all tests are found const allTestsFound = Array.from(testNameFound.values()).every(value => value === true); if (allTestsFound){ break; } } // Handle each relevant check relevantChecks.forEach((commit, check) => { if ( check.status === 'completed' && check.conclusion === 'success' ) { updateIssues( check.name, `[Tests are passing](${check.html_url}) for commit [${commit.sha}](${commit.html_url}).` ); } else if (check.status === 'in_progress') { console.log( `Check is pending ${check.html_url} for ${commit.html_url}. Retry again later.` ); } else { createOrCommentIssue( `Cloud Build Failure Reporter: ${check.name} failed`, `Cloud Build Failure Reporter found test failure for [**${check.name}** ](${check.html_url}) at [${commit.sha}](${commit.html_url}). Please fix the error and then close the issue after the **${check.name}** test passes.` ); } }); // no periodic checks found across all commits, report it const noTestFound = Array.from(testNameFound.values()).every(value => value === false); if (noTestFound){ createOrCommentIssue( 'Missing periodic tests: ${{ inputs.trigger_names }}', `No periodic test is found for triggers: ${{ inputs.trigger_names }}. Last checked from ${ commits[0].html_url } to ${commits[commits.length - 1].html_url}.` ); } ``` -------------------------------------------------------------------------------- /internal/server/api_test.go: -------------------------------------------------------------------------------- ```go // Copyright 2024 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 server import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "github.com/googleapis/genai-toolbox/internal/tools" ) func TestToolsetEndpoint(t *testing.T) { mockTools := []MockTool{tool1, tool2} toolsMap, toolsets := setUpResources(t, mockTools) r, shutdown := setUpServer(t, "api", toolsMap, toolsets) defer shutdown() ts := runServer(r, false) defer ts.Close() // wantResponse is a struct for checks against test cases type wantResponse struct { statusCode int isErr bool version string tools []string } testCases := []struct { name string toolsetName string want wantResponse }{ { name: "'default' manifest", toolsetName: "", want: wantResponse{ statusCode: http.StatusOK, version: fakeVersionString, tools: []string{tool1.Name, tool2.Name}, }, }, { name: "invalid toolset name", toolsetName: "some_imaginary_toolset", want: wantResponse{ statusCode: http.StatusNotFound, isErr: true, }, }, { name: "single toolset 1", toolsetName: "tool1_only", want: wantResponse{ statusCode: http.StatusOK, version: fakeVersionString, tools: []string{tool1.Name}, }, }, { name: "single toolset 2", toolsetName: "tool2_only", want: wantResponse{ statusCode: http.StatusOK, version: fakeVersionString, tools: []string{tool2.Name}, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { resp, body, err := runRequest(ts, http.MethodGet, fmt.Sprintf("/toolset/%s", tc.toolsetName), nil, nil) if err != nil { t.Fatalf("unexpected error during request: %s", err) } if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) } if resp.StatusCode != tc.want.statusCode { t.Logf("response body: %s", body) t.Fatalf("unexpected status code: want %d, got %d", tc.want.statusCode, resp.StatusCode) } if tc.want.isErr { // skip the rest of the checks if this is an error case return } var m tools.ToolsetManifest err = json.Unmarshal(body, &m) if err != nil { t.Fatalf("unable to parse ToolsetManifest: %s", err) } // Check the version is correct if m.ServerVersion != tc.want.version { t.Fatalf("unexpected ServerVersion: want %q, got %q", tc.want.version, m.ServerVersion) } // validate that the tools in the toolset are correct for _, name := range tc.want.tools { _, ok := m.ToolsManifest[name] if !ok { t.Errorf("%q tool not found in manifest", name) } } }) } } func TestToolGetEndpoint(t *testing.T) { mockTools := []MockTool{tool1, tool2} toolsMap, toolsets := setUpResources(t, mockTools) r, shutdown := setUpServer(t, "api", toolsMap, toolsets) defer shutdown() ts := runServer(r, false) defer ts.Close() // wantResponse is a struct for checks against test cases type wantResponse struct { statusCode int isErr bool version string tools []string } testCases := []struct { name string toolName string want wantResponse }{ { name: "tool1", toolName: tool1.Name, want: wantResponse{ statusCode: http.StatusOK, version: fakeVersionString, tools: []string{tool1.Name}, }, }, { name: "tool2", toolName: tool2.Name, want: wantResponse{ statusCode: http.StatusOK, version: fakeVersionString, tools: []string{tool2.Name}, }, }, { name: "invalid tool", toolName: "some_imaginary_tool", want: wantResponse{ statusCode: http.StatusNotFound, isErr: true, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { resp, body, err := runRequest(ts, http.MethodGet, fmt.Sprintf("/tool/%s", tc.toolName), nil, nil) if err != nil { t.Fatalf("unexpected error during request: %s", err) } if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) } if resp.StatusCode != tc.want.statusCode { t.Logf("response body: %s", body) t.Fatalf("unexpected status code: want %d, got %d", tc.want.statusCode, resp.StatusCode) } if tc.want.isErr { // skip the rest of the checks if this is an error case return } var m tools.ToolsetManifest err = json.Unmarshal(body, &m) if err != nil { t.Fatalf("unable to parse ToolsetManifest: %s", err) } // Check the version is correct if m.ServerVersion != tc.want.version { t.Fatalf("unexpected ServerVersion: want %q, got %q", tc.want.version, m.ServerVersion) } // validate that the tools in the toolset are correct for _, name := range tc.want.tools { _, ok := m.ToolsManifest[name] if !ok { t.Errorf("%q tool not found in manifest", name) } } }) } } func TestToolInvokeEndpoint(t *testing.T) { mockTools := []MockTool{tool1, tool2, tool4, tool5} toolsMap, toolsets := setUpResources(t, mockTools) r, shutdown := setUpServer(t, "api", toolsMap, toolsets) defer shutdown() ts := runServer(r, false) defer ts.Close() testCases := []struct { name string toolName string requestBody io.Reader want string isErr bool }{ { name: "tool1", toolName: tool1.Name, requestBody: bytes.NewBuffer([]byte(`{}`)), want: "{result:[no_params]}\n", isErr: false, }, { name: "tool2", toolName: tool2.Name, requestBody: bytes.NewBuffer([]byte(`{"param1": 1, "param2": 2}`)), want: "{result:[some_params]}\n", isErr: false, }, { name: "invalid tool", toolName: "some_imaginary_tool", requestBody: bytes.NewBuffer([]byte(`{}`)), want: "", isErr: true, }, { name: "tool4", toolName: tool4.Name, requestBody: bytes.NewBuffer([]byte(`{}`)), want: "", isErr: true, }, { name: "tool5", toolName: tool5.Name, requestBody: bytes.NewBuffer([]byte(`{}`)), want: "", isErr: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { resp, body, err := runRequest(ts, http.MethodPost, fmt.Sprintf("/tool/%s/invoke", tc.toolName), tc.requestBody, nil) if err != nil { t.Fatalf("unexpected error during request: %s", err) } if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) } if resp.StatusCode != http.StatusOK { if tc.isErr == true { return } t.Fatalf("response status code is not 200, got %d, %s", resp.StatusCode, string(body)) } got := string(body) // Remove `\` and `"` for string comparison got = strings.ReplaceAll(got, "\\", "") want := strings.ReplaceAll(tc.want, "\\", "") got = strings.ReplaceAll(got, "\"", "") want = strings.ReplaceAll(want, "\"", "") if got != want { t.Fatalf("unexpected value: got %q, want %q", got, tc.want) } }) } } ``` -------------------------------------------------------------------------------- /internal/server/config.go: -------------------------------------------------------------------------------- ```go // Copyright 2024 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 server import ( "context" "fmt" "strings" yaml "github.com/goccy/go-yaml" "github.com/googleapis/genai-toolbox/internal/auth" "github.com/googleapis/genai-toolbox/internal/auth/google" "github.com/googleapis/genai-toolbox/internal/sources" "github.com/googleapis/genai-toolbox/internal/tools" "github.com/googleapis/genai-toolbox/internal/util" ) type ServerConfig struct { // Server version Version string // Address is the address of the interface the server will listen on. Address string // Port is the port the server will listen on. Port int // SourceConfigs defines what sources of data are available for tools. SourceConfigs SourceConfigs // AuthServiceConfigs defines what sources of authentication are available for tools. AuthServiceConfigs AuthServiceConfigs // ToolConfigs defines what tools are available. ToolConfigs ToolConfigs // ToolsetConfigs defines what tools are available. ToolsetConfigs ToolsetConfigs // LoggingFormat defines whether structured loggings are used. LoggingFormat logFormat // LogLevel defines the levels to log. LogLevel StringLevel // TelemetryGCP defines whether GCP exporter is used. TelemetryGCP bool // TelemetryOTLP defines OTLP collector url for telemetry exports. TelemetryOTLP string // TelemetryServiceName defines the value of service.name resource attribute. TelemetryServiceName string // Stdio indicates if Toolbox is listening via MCP stdio. Stdio bool // DisableReload indicates if the user has disabled dynamic reloading for Toolbox. DisableReload bool // UI indicates if Toolbox UI endpoints (/ui) are available UI bool } type logFormat string // String is used by both fmt.Print and by Cobra in help text func (f *logFormat) String() string { if string(*f) != "" { return strings.ToLower(string(*f)) } return "standard" } // validate logging format flag func (f *logFormat) Set(v string) error { switch strings.ToLower(v) { case "standard", "json": *f = logFormat(v) return nil default: return fmt.Errorf(`log format must be one of "standard", or "json"`) } } // Type is used in Cobra help text func (f *logFormat) Type() string { return "logFormat" } type StringLevel string // String is used by both fmt.Print and by Cobra in help text func (s *StringLevel) String() string { if string(*s) != "" { return strings.ToLower(string(*s)) } return "info" } // validate log level flag func (s *StringLevel) Set(v string) error { switch strings.ToLower(v) { case "debug", "info", "warn", "error": *s = StringLevel(v) return nil default: return fmt.Errorf(`log level must be one of "debug", "info", "warn", or "error"`) } } // Type is used in Cobra help text func (s *StringLevel) Type() string { return "stringLevel" } // SourceConfigs is a type used to allow unmarshal of the data source config map type SourceConfigs map[string]sources.SourceConfig // validate interface var _ yaml.InterfaceUnmarshalerContext = &SourceConfigs{} func (c *SourceConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { *c = make(SourceConfigs) // Parse the 'kind' fields for each source var raw map[string]util.DelayedUnmarshaler if err := unmarshal(&raw); err != nil { return err } for name, u := range raw { // Unmarshal to a general type that ensure it capture all fields var v map[string]any if err := u.Unmarshal(&v); err != nil { return fmt.Errorf("unable to unmarshal %q: %w", name, err) } kind, ok := v["kind"] if !ok { return fmt.Errorf("missing 'kind' field for source %q", name) } kindStr, ok := kind.(string) if !ok { return fmt.Errorf("invalid 'kind' field for source %q (must be a string)", name) } yamlDecoder, err := util.NewStrictDecoder(v) if err != nil { return fmt.Errorf("error creating YAML decoder for source %q: %w", name, err) } sourceConfig, err := sources.DecodeConfig(ctx, kindStr, name, yamlDecoder) if err != nil { return err } (*c)[name] = sourceConfig } return nil } // AuthServiceConfigs is a type used to allow unmarshal of the data authService config map type AuthServiceConfigs map[string]auth.AuthServiceConfig // validate interface var _ yaml.InterfaceUnmarshalerContext = &AuthServiceConfigs{} func (c *AuthServiceConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { *c = make(AuthServiceConfigs) // Parse the 'kind' fields for each authService var raw map[string]util.DelayedUnmarshaler if err := unmarshal(&raw); err != nil { return err } for name, u := range raw { var v map[string]any if err := u.Unmarshal(&v); err != nil { return fmt.Errorf("unable to unmarshal %q: %w", name, err) } kind, ok := v["kind"] if !ok { return fmt.Errorf("missing 'kind' field for %q", name) } dec, err := util.NewStrictDecoder(v) if err != nil { return fmt.Errorf("error creating decoder: %w", err) } switch kind { case google.AuthServiceKind: actual := google.Config{Name: name} if err := dec.DecodeContext(ctx, &actual); err != nil { return fmt.Errorf("unable to parse as %q: %w", kind, err) } (*c)[name] = actual default: return fmt.Errorf("%q is not a valid kind of auth source", kind) } } return nil } // ToolConfigs is a type used to allow unmarshal of the tool configs type ToolConfigs map[string]tools.ToolConfig // validate interface var _ yaml.InterfaceUnmarshalerContext = &ToolConfigs{} func (c *ToolConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { *c = make(ToolConfigs) // Parse the 'kind' fields for each source var raw map[string]util.DelayedUnmarshaler if err := unmarshal(&raw); err != nil { return err } for name, u := range raw { var v map[string]any if err := u.Unmarshal(&v); err != nil { return fmt.Errorf("unable to unmarshal %q: %w", name, err) } // `authRequired` and `useClientOAuth` cannot be specified together if v["authRequired"] != nil && v["useClientOAuth"] == true { return fmt.Errorf("`authRequired` and `useClientOAuth` are mutually exclusive. Choose only one authentication method") } // Make `authRequired` an empty list instead of nil for Tool manifest if v["authRequired"] == nil { v["authRequired"] = []string{} } kindVal, ok := v["kind"] if !ok { return fmt.Errorf("missing 'kind' field for tool %q", name) } kindStr, ok := kindVal.(string) if !ok { return fmt.Errorf("invalid 'kind' field for tool %q (must be a string)", name) } yamlDecoder, err := util.NewStrictDecoder(v) if err != nil { return fmt.Errorf("error creating YAML decoder for tool %q: %w", name, err) } toolCfg, err := tools.DecodeConfig(ctx, kindStr, name, yamlDecoder) if err != nil { return err } (*c)[name] = toolCfg } return nil } // ToolConfigs is a type used to allow unmarshal of the toolset configs type ToolsetConfigs map[string]tools.ToolsetConfig // validate interface var _ yaml.InterfaceUnmarshalerContext = &ToolsetConfigs{} func (c *ToolsetConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { *c = make(ToolsetConfigs) var raw map[string][]string if err := unmarshal(&raw); err != nil { return err } for name, toolList := range raw { (*c)[name] = tools.ToolsetConfig{Name: name, ToolNames: toolList} } return nil } ``` -------------------------------------------------------------------------------- /internal/tools/sqlite/sqliteexecutesql/sqliteexecutesql_test.go: -------------------------------------------------------------------------------- ```go // 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 sqliteexecutesql_test import ( "context" "database/sql" "reflect" "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" "github.com/googleapis/genai-toolbox/internal/tools" "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqliteexecutesql" _ "modernc.org/sqlite" ) func TestParseFromYamlExecuteSql(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: sqlite-execute-sql source: my-instance description: some description authRequired: - my-google-auth-service - other-auth-service `, want: server.ToolConfigs{ "example_tool": sqliteexecutesql.Config{ Name: "example_tool", Kind: "sqlite-execute-sql", Source: "my-instance", Description: "some description", AuthRequired: []string{"my-google-auth-service", "other-auth-service"}, }, }, }, } 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 setupTestDB(t *testing.T) *sql.DB { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("Failed to open in-memory database: %v", err) } return db } func TestTool_Invoke(t *testing.T) { ctx, err := testutils.ContextWithNewLogger() if err != nil { t.Fatalf("unexpected error: %s", err) } type fields struct { Name string Kind string AuthRequired []string Parameters tools.Parameters DB *sql.DB } type args struct { ctx context.Context params tools.ParamValues accessToken tools.AccessToken } tests := []struct { name string fields fields args args want any wantErr bool }{ { name: "create table", fields: fields{ DB: setupTestDB(t), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"}, }, }, want: nil, wantErr: false, }, { name: "insert data", fields: fields{ DB: setupTestDB(t), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER); INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30), (2, 'Bob', 25)"}, }, }, want: nil, wantErr: false, }, { name: "select data", fields: fields{ DB: func() *sql.DB { db := setupTestDB(t) if _, err := db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER); INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30), (2, 'Bob', 25)"); err != nil { t.Fatalf("Failed to set up database for select: %v", err) } return db }(), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "SELECT * FROM users"}, }, }, want: []any{ map[string]any{"id": int64(1), "name": "Alice", "age": int64(30)}, map[string]any{"id": int64(2), "name": "Bob", "age": int64(25)}, }, wantErr: false, }, { name: "drop table", fields: fields{ DB: func() *sql.DB { db := setupTestDB(t) if _, err := db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"); err != nil { t.Fatalf("Failed to set up database for drop: %v", err) } return db }(), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "DROP TABLE users"}, }, }, want: nil, wantErr: false, }, { name: "invalid sql", fields: fields{ DB: setupTestDB(t), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "SELECT * FROM non_existent_table"}, }, }, want: nil, wantErr: true, }, { name: "empty sql", fields: fields{ DB: setupTestDB(t), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: ""}, }, }, want: nil, wantErr: true, }, { name: "data types", fields: fields{ DB: func() *sql.DB { db := setupTestDB(t) if _, err := db.Exec("CREATE TABLE data_types (id INTEGER PRIMARY KEY, null_col TEXT, blob_col BLOB)"); err != nil { t.Fatalf("Failed to set up database for data types: %v", err) } if _, err := db.Exec("INSERT INTO data_types (id, null_col, blob_col) VALUES (1, NULL, ?)", []byte{1, 2, 3}); err != nil { t.Fatalf("Failed to insert data for data types: %v", err) } return db }(), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "SELECT * FROM data_types"}, }, }, want: []any{ map[string]any{"id": int64(1), "null_col": nil, "blob_col": []byte{1, 2, 3}}, }, wantErr: false, }, { name: "join operation", fields: fields{ DB: func() *sql.DB { db := setupTestDB(t) if _, err := db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"); err != nil { t.Fatalf("Failed to set up database for join: %v", err) } if _, err := db.Exec("INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30), (2, 'Bob', 25)"); err != nil { t.Fatalf("Failed to insert data for join: %v", err) } if _, err := db.Exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, item TEXT)"); err != nil { t.Fatalf("Failed to set up database for join: %v", err) } if _, err := db.Exec("INSERT INTO orders (id, user_id, item) VALUES (1, 1, 'Laptop'), (2, 2, 'Keyboard')"); err != nil { t.Fatalf("Failed to insert data for join: %v", err) } return db }(), }, args: args{ ctx: ctx, params: []tools.ParamValue{ {Name: "sql", Value: "SELECT u.name, o.item FROM users u JOIN orders o ON u.id = o.user_id"}, }, }, want: []any{ map[string]any{"name": "Alice", "item": "Laptop"}, map[string]any{"name": "Bob", "item": "Keyboard"}, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr := &sqliteexecutesql.Tool{ Name: tt.fields.Name, Kind: tt.fields.Kind, AuthRequired: tt.fields.AuthRequired, Parameters: tt.fields.Parameters, DB: tt.fields.DB, } got, err := tr.Invoke(tt.args.ctx, tt.args.params, tt.args.accessToken) if (err != nil) != tt.wantErr { t.Errorf("Tool.Invoke() error = %v, wantErr %v", err, tt.wantErr) return } isEqual := false if got != nil && len(got.([]any)) == 0 && len(tt.want.([]any)) == 0 { isEqual = true // Special case for empty slices, since DeepEqual returns false } else { isEqual = reflect.DeepEqual(got, tt.want) } if !isEqual { t.Errorf("Tool.Invoke() = %v, want %v", got, tt.want) } }) } } ``` -------------------------------------------------------------------------------- /docs/en/how-to/deploy_toolbox.md: -------------------------------------------------------------------------------- ```markdown --- title: "Deploy to Cloud Run" type: docs weight: 3 description: > How to set up and configure Toolbox to run on Cloud Run. --- ## Before you begin 1. [Install](https://cloud.google.com/sdk/docs/install) the Google Cloud CLI. 1. Set the PROJECT_ID environment variable: ```bash export PROJECT_ID="my-project-id" ``` 1. Initialize gcloud CLI: ```bash gcloud init gcloud config set project $PROJECT_ID ``` 1. Make sure you've set up and initialized your database. 1. You must have the following APIs enabled: ```bash gcloud services enable run.googleapis.com \ cloudbuild.googleapis.com \ artifactregistry.googleapis.com \ iam.googleapis.com \ secretmanager.googleapis.com ``` 1. To create an IAM account, you must have the following IAM permissions (or roles): - Create Service Account role (roles/iam.serviceAccountCreator) 1. To create a secret, you must have the following roles: - Secret Manager Admin role (roles/secretmanager.admin) 1. To deploy to Cloud Run, you must have the following set of roles: - Cloud Run Developer (roles/run.developer) - Service Account User role (roles/iam.serviceAccountUser) {{< notice note >}} If you are using sources that require VPC-access (such as AlloyDB or Cloud SQL over private IP), make sure your Cloud Run service and the database are in the same VPC network. {{< /notice >}} ## Create a service account 1. Create a backend service account if you don't already have one: ```bash gcloud iam service-accounts create toolbox-identity ``` 1. Grant permissions to use secret manager: ```bash gcloud projects add-iam-policy-binding $PROJECT_ID \ --member serviceAccount:toolbox-identity@$PROJECT_ID.iam.gserviceaccount.com \ --role roles/secretmanager.secretAccessor ``` 1. Grant additional permissions to the service account that are specific to the source, e.g.: - [AlloyDB for PostgreSQL](../resources/sources/alloydb-pg.md#iam-permissions) - [Cloud SQL for PostgreSQL](../resources/sources/cloud-sql-pg.md#iam-permissions) ## Configure `tools.yaml` file Create a `tools.yaml` file that contains your configuration for Toolbox. For details, see the [configuration](../resources/sources/) section. ## Deploy to Cloud Run 1. Upload `tools.yaml` as a secret: ```bash gcloud secrets create tools --data-file=tools.yaml ``` If you already have a secret and want to update the secret version, execute the following: ```bash gcloud secrets versions add tools --data-file=tools.yaml ``` 1. Set an environment variable to the container image that you want to use for cloud run: ```bash export IMAGE=us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest ``` {{< notice note >}} **The `$PORT` Environment Variable** Google Cloud Run dictates the port your application must listen on by setting the `$PORT` environment variable inside your container. This value defaults to **8080**. Your application's `--port` argument **must** be set to listen on this port. If there is a mismatch, the container will fail to start and the deployment will time out. {{< /notice >}} 1. Deploy Toolbox to Cloud Run using the following command: ```bash gcloud run deploy toolbox \ --image $IMAGE \ --service-account toolbox-identity \ --region us-central1 \ --set-secrets "/app/tools.yaml=tools:latest" \ --args="--tools-file=/app/tools.yaml","--address=0.0.0.0","--port=8080" # --allow-unauthenticated # https://cloud.google.com/run/docs/authenticating/public#gcloud ``` If you are using a VPC network, use the command below: ```bash gcloud run deploy toolbox \ --image $IMAGE \ --service-account toolbox-identity \ --region us-central1 \ --set-secrets "/app/tools.yaml=tools:latest" \ --args="--tools-file=/app/tools.yaml","--address=0.0.0.0","--port=8080" \ # TODO(dev): update the following to match your VPC if necessary --network default \ --subnet default # --allow-unauthenticated # https://cloud.google.com/run/docs/authenticating/public#gcloud ``` ## Connecting with Toolbox Client SDK You can connect to Toolbox Cloud Run instances directly through the SDK. 1. [Set up `Cloud Run Invoker` role access](https://cloud.google.com/run/docs/securing/managing-access#service-add-principals) to your Cloud Run service. 1. (Only for local runs) Set up [Application Default Credentials](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment) for the principal you set up the `Cloud Run Invoker` role access to. 1. Run the following to retrieve a non-deterministic URL for the cloud run service: ```bash gcloud run services describe toolbox --format 'value(status.url)' ``` 1. Import and initialize the toolbox client with the URL retrieved above: {{< tabpane persist=header >}} {{< tab header="Python" lang="python" >}} from toolbox_core import ToolboxClient, auth_methods # Replace with the Cloud Run service URL generated in the previous step. URL = "https://cloud-run-url.app" auth_token_provider = auth_methods.aget_google_id_token(URL) # can also use sync method async with ToolboxClient( URL, client_headers={"Authorization": auth_token_provider}, ) as toolbox: {{< /tab >}} {{< tab header="Javascript" lang="javascript" >}} import { ToolboxClient } from '@toolbox-sdk/core'; import {getGoogleIdToken} from '@toolbox-sdk/core/auth' // Replace with the Cloud Run service URL generated in the previous step. const URL = 'http://127.0.0.1:5000'; const authTokenProvider = () => getGoogleIdToken(URL); const client = new ToolboxClient(URL, null, {"Authorization": authTokenProvider}); {{< /tab >}} {{< tab header="Go" lang="go" >}} import "github.com/googleapis/mcp-toolbox-sdk-go/core" func main() { // Replace with the Cloud Run service URL generated in the previous step. URL := "http://127.0.0.1:5000" auth_token_provider, err := core.GetGoogleIDToken(ctx, URL) if err != nil { log.Fatalf("Failed to fetch token %v", err) } toolboxClient, err := core.NewToolboxClient( URL, core.WithClientHeaderString("Authorization", auth_token_provider)) if err != nil { log.Fatalf("Failed to create Toolbox client: %v", err) } } {{< /tab >}} {{< /tabpane >}} Now, you can use this client to connect to the deployed Cloud Run instance! ## Troubleshooting {{< notice note >}} For any deployment or runtime error, the best first step is to check the logs for your service in the Google Cloud Console's Cloud Run section. They often contain the specific error message needed to diagnose the problem. {{< /notice >}} * **Deployment Fails with "Container failed to start":** This is almost always caused by a port mismatch. Ensure your container's `--port` argument is set to `8080` to match the `$PORT` environment variable provided by Cloud Run. * **Client Receives Permission Denied Error (401 or 403):** If your client application (e.g., your local SDK) gets a `401 Unauthorized` or `403 Forbidden` error when trying to call your Cloud Run service, it means the client is not properly authenticated as an invoker. * Ensure the user or service account calling the service has the **Cloud Run Invoker** (`roles/run.invoker`) IAM role. * If running locally, make sure your Application Default Credentials are set up correctly by running `gcloud auth application-default login`. * **Service Fails to Access Secrets (in logs):** If your application starts but the logs show errors like "permission denied" when trying to access Secret Manager, it means the Toolbox service account is missing permissions. * Ensure the `toolbox-identity` service account has the **Secret Manager Secret Accessor** (`roles/secretmanager.secretAccessor`) IAM role. ``` -------------------------------------------------------------------------------- /internal/tools/common_test.go: -------------------------------------------------------------------------------- ```go // 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 tools_test import ( "strings" "testing" "text/template" "github.com/google/go-cmp/cmp" "github.com/googleapis/genai-toolbox/internal/tools" ) func TestPopulateTemplate(t *testing.T) { tcs := []struct { name string templateName string templateString string data map[string]any want string wantErr bool }{ { name: "simple string substitution", templateName: "test", templateString: "Hello {{.name}}!", data: map[string]any{"name": "World"}, want: "Hello World!", wantErr: false, }, { name: "multiple substitutions", templateName: "test", templateString: "{{.greeting}} {{.name}}, you are {{.age}} years old", data: map[string]any{"greeting": "Hello", "name": "Alice", "age": 30}, want: "Hello Alice, you are 30 years old", wantErr: false, }, { name: "empty template", templateName: "test", templateString: "", data: map[string]any{}, want: "", wantErr: false, }, { name: "no substitutions", templateName: "test", templateString: "Plain text without templates", data: map[string]any{}, want: "Plain text without templates", wantErr: false, }, { name: "invalid template syntax", templateName: "test", templateString: "{{.name", data: map[string]any{"name": "World"}, want: "", wantErr: true, }, { name: "missing field", templateName: "test", templateString: "{{.missing}}", data: map[string]any{"name": "World"}, want: "<no value>", wantErr: false, }, { name: "invalid function call", templateName: "test", templateString: "{{.name.invalid}}", data: map[string]any{"name": "World"}, want: "", wantErr: true, }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { got, err := tools.PopulateTemplate(tc.templateName, tc.templateString, tc.data) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(tc.want, got); diff != "" { t.Fatalf("incorrect result (-want +got):\n%s", diff) } }) } } func TestPopulateTemplateWithFunc(t *testing.T) { // Custom function for testing customFuncs := template.FuncMap{ "upper": strings.ToUpper, "add": func(a, b int) int { return a + b }, } tcs := []struct { name string templateName string templateString string data map[string]any funcMap template.FuncMap want string wantErr bool }{ { name: "with custom upper function", templateName: "test", templateString: "{{upper .text}}", data: map[string]any{"text": "hello"}, funcMap: customFuncs, want: "HELLO", wantErr: false, }, { name: "with custom add function", templateName: "test", templateString: "Result: {{add .x .y}}", data: map[string]any{"x": 5, "y": 3}, funcMap: customFuncs, want: "Result: 8", wantErr: false, }, { name: "nil funcMap", templateName: "test", templateString: "Hello {{.name}}", data: map[string]any{"name": "World"}, funcMap: nil, want: "Hello World", wantErr: false, }, { name: "combine custom function with regular substitution", templateName: "test", templateString: "{{upper .greeting}} {{.name}}!", data: map[string]any{"greeting": "hello", "name": "Alice"}, funcMap: customFuncs, want: "HELLO Alice!", wantErr: false, }, { name: "undefined function", templateName: "test", templateString: "{{undefined .text}}", data: map[string]any{"text": "hello"}, funcMap: nil, want: "", wantErr: true, }, { name: "wrong number of arguments", templateName: "test", templateString: "{{upper}}", data: map[string]any{}, funcMap: template.FuncMap{"upper": strings.ToUpper}, want: "", wantErr: true, }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { got, err := tools.PopulateTemplateWithFunc(tc.templateName, tc.templateString, tc.data, tc.funcMap) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(tc.want, got); diff != "" { t.Fatalf("incorrect result (-want +got):\n%s", diff) } }) } } func TestPopulateTemplateWithJSON(t *testing.T) { tcs := []struct { name string templateName string templateString string data map[string]any want string wantErr bool }{ { name: "json string", templateName: "test", templateString: "Data: {{json .value}}", data: map[string]any{"value": "hello"}, want: `Data: "hello"`, wantErr: false, }, { name: "json number", templateName: "test", templateString: "Number: {{json .num}}", data: map[string]any{"num": 42}, want: "Number: 42", wantErr: false, }, { name: "json boolean", templateName: "test", templateString: "Bool: {{json .flag}}", data: map[string]any{"flag": true}, want: "Bool: true", wantErr: false, }, { name: "json array", templateName: "test", templateString: "Array: {{json .items}}", data: map[string]any{"items": []any{"a", "b", "c"}}, want: `Array: ["a","b","c"]`, wantErr: false, }, { name: "json object", templateName: "test", templateString: "Object: {{json .obj}}", data: map[string]any{"obj": map[string]any{"name": "Alice", "age": 30}}, want: `Object: {"age":30,"name":"Alice"}`, wantErr: false, }, { name: "json null", templateName: "test", templateString: "Null: {{json .nullValue}}", data: map[string]any{"nullValue": nil}, want: "Null: null", wantErr: false, }, { name: "combine json with regular substitution", templateName: "test", templateString: "User {{.name}} has data: {{json .data}}", data: map[string]any{"name": "Bob", "data": map[string]any{"id": 123}}, want: `User Bob has data: {"id":123}`, wantErr: false, }, { name: "missing field for json", templateName: "test", templateString: "{{json .missing}}", data: map[string]any{}, want: "null", wantErr: false, }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { got, err := tools.PopulateTemplateWithJSON(tc.templateName, tc.templateString, tc.data) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(tc.want, got); diff != "" { t.Fatalf("incorrect result (-want +got):\n%s", diff) } }) } } ``` -------------------------------------------------------------------------------- /tests/cloudsqlmysql/cloud_sql_mysql_create_instance_integration_test.go: -------------------------------------------------------------------------------- ```go // 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 cloudsqlmysql_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "regexp" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/googleapis/genai-toolbox/internal/testutils" "github.com/googleapis/genai-toolbox/tests" "google.golang.org/api/sqladmin/v1" ) var ( createInstanceToolKind = "cloud-sql-mysql-create-instance" ) type createInstanceTransport struct { transport http.RoundTripper url *url.URL } func (t *createInstanceTransport) 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 masterHandler struct { t *testing.T } func (h *masterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.UserAgent(), "genai-toolbox/") { h.t.Errorf("User-Agent header not found") } var body sqladmin.DatabaseInstance if err := json.NewDecoder(r.Body).Decode(&body); err != nil { h.t.Fatalf("failed to decode request body: %v", err) } instanceName := body.Name if instanceName == "" { http.Error(w, "missing instance name", http.StatusBadRequest) return } var expectedBody sqladmin.DatabaseInstance var response any var statusCode int switch instanceName { case "instance1": expectedBody = sqladmin.DatabaseInstance{ Project: "p1", Name: "instance1", DatabaseVersion: "MYSQL_8_0", RootPassword: "password123", Settings: &sqladmin.Settings{ AvailabilityType: "REGIONAL", Edition: "ENTERPRISE_PLUS", Tier: "db-perf-optimized-N-8", DataDiskSizeGb: 250, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op1", "status": "PENDING"} statusCode = http.StatusOK case "instance2": expectedBody = sqladmin.DatabaseInstance{ Project: "p2", Name: "instance2", DatabaseVersion: "MYSQL_8_4", RootPassword: "password456", Settings: &sqladmin.Settings{ AvailabilityType: "ZONAL", Edition: "ENTERPRISE_PLUS", Tier: "db-perf-optimized-N-2", DataDiskSizeGb: 100, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op2", "status": "RUNNING"} statusCode = http.StatusOK default: http.Error(w, fmt.Sprintf("unhandled instance name: %s", instanceName), http.StatusInternalServerError) return } if expectedBody.Project != body.Project { h.t.Errorf("unexpected project: got %q, want %q", body.Project, expectedBody.Project) } if expectedBody.Name != body.Name { h.t.Errorf("unexpected name: got %q, want %q", body.Name, expectedBody.Name) } if expectedBody.DatabaseVersion != body.DatabaseVersion { h.t.Errorf("unexpected databaseVersion: got %q, want %q", body.DatabaseVersion, expectedBody.DatabaseVersion) } if expectedBody.RootPassword != body.RootPassword { h.t.Errorf("unexpected rootPassword: got %q, want %q", body.RootPassword, expectedBody.RootPassword) } if diff := cmp.Diff(expectedBody.Settings, body.Settings); diff != "" { h.t.Errorf("unexpected request body settings (-want +got):\n%s", diff) } 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 TestCreateInstanceToolEndpoints(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() handler := &masterHandler{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 = &createInstanceTransport{ transport: originalTransport, url: serverURL, } t.Cleanup(func() { http.DefaultClient.Transport = originalTransport }) var args []string toolsFile := getCreateInstanceToolsConfig() 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, 10*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 creation - production", toolName: "create-instance-prod", body: `{"project": "p1", "name": "instance1", "databaseVersion": "MYSQL_8_0", "rootPassword": "password123", "editionPreset": "Production"}`, want: `{"name":"op1","status":"PENDING"}`, }, { name: "successful creation - development", toolName: "create-instance-dev", body: `{"project": "p2", "name": "instance2", "rootPassword": "password456", "editionPreset": "Development"}`, want: `{"name":"op2","status":"RUNNING"}`, }, { name: "missing required parameter", toolName: "create-instance-prod", body: `{"name": "instance1"}`, 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 getCreateInstanceToolsConfig() 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{ "create-instance-prod": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, "create-instance-dev": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, }, } } ``` -------------------------------------------------------------------------------- /tests/cloudsqlmssql/cloud_sql_mssql_create_instance_integration_test.go: -------------------------------------------------------------------------------- ```go // 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 cloudsqlmssql_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "regexp" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/googleapis/genai-toolbox/internal/testutils" "github.com/googleapis/genai-toolbox/tests" "google.golang.org/api/sqladmin/v1" ) var ( createInstanceToolKind = "cloud-sql-mssql-create-instance" ) type createInstanceTransport struct { transport http.RoundTripper url *url.URL } func (t *createInstanceTransport) 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 masterHandler struct { t *testing.T } func (h *masterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.UserAgent(), "genai-toolbox/") { h.t.Errorf("User-Agent header not found") } var body sqladmin.DatabaseInstance if err := json.NewDecoder(r.Body).Decode(&body); err != nil { h.t.Fatalf("failed to decode request body: %v", err) } instanceName := body.Name if instanceName == "" { http.Error(w, "missing instance name", http.StatusBadRequest) return } var expectedBody sqladmin.DatabaseInstance var response any var statusCode int switch instanceName { case "instance1": expectedBody = sqladmin.DatabaseInstance{ Project: "p1", Name: "instance1", DatabaseVersion: "SQLSERVER_2022_ENTERPRISE", RootPassword: "password123", Settings: &sqladmin.Settings{ AvailabilityType: "REGIONAL", Edition: "ENTERPRISE", Tier: "db-custom-4-26624", DataDiskSizeGb: 250, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op1", "status": "PENDING"} statusCode = http.StatusOK case "instance2": expectedBody = sqladmin.DatabaseInstance{ Project: "p2", Name: "instance2", DatabaseVersion: "SQLSERVER_2022_STANDARD", RootPassword: "password456", Settings: &sqladmin.Settings{ AvailabilityType: "ZONAL", Edition: "ENTERPRISE", Tier: "db-custom-2-8192", DataDiskSizeGb: 100, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op2", "status": "RUNNING"} statusCode = http.StatusOK default: http.Error(w, fmt.Sprintf("unhandled instance name: %s", instanceName), http.StatusInternalServerError) return } if expectedBody.Project != body.Project { h.t.Errorf("unexpected project: got %q, want %q", body.Project, expectedBody.Project) } if expectedBody.Name != body.Name { h.t.Errorf("unexpected name: got %q, want %q", body.Name, expectedBody.Name) } if expectedBody.DatabaseVersion != body.DatabaseVersion { h.t.Errorf("unexpected databaseVersion: got %q, want %q", body.DatabaseVersion, expectedBody.DatabaseVersion) } if expectedBody.RootPassword != body.RootPassword { h.t.Errorf("unexpected rootPassword: got %q, want %q", body.RootPassword, expectedBody.RootPassword) } if diff := cmp.Diff(expectedBody.Settings, body.Settings); diff != "" { h.t.Errorf("unexpected request body settings (-want +got):\n%s", diff) } 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 TestCreateInstanceToolEndpoints(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() handler := &masterHandler{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 = &createInstanceTransport{ transport: originalTransport, url: serverURL, } t.Cleanup(func() { http.DefaultClient.Transport = originalTransport }) var args []string toolsFile := getCreateInstanceToolsConfig() 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, 10*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 creation - production", toolName: "create-instance-prod", body: `{"project": "p1", "name": "instance1", "databaseVersion": "SQLSERVER_2022_ENTERPRISE", "rootPassword": "password123", "editionPreset": "Production"}`, want: `{"name":"op1","status":"PENDING"}`, }, { name: "successful creation - development", toolName: "create-instance-dev", body: `{"project": "p2", "name": "instance2", "rootPassword": "password456", "editionPreset": "Development"}`, want: `{"name":"op2","status":"RUNNING"}`, }, { name: "missing required parameter", toolName: "create-instance-prod", body: `{"name": "instance1"}`, 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 getCreateInstanceToolsConfig() 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{ "create-instance-prod": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, "create-instance-dev": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, }, } } ``` -------------------------------------------------------------------------------- /tests/cloudsqlpg/cloud_sql_pg_create_instances_test.go: -------------------------------------------------------------------------------- ```go // 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 cloudsqlpg import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "regexp" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/googleapis/genai-toolbox/internal/testutils" "github.com/googleapis/genai-toolbox/tests" "google.golang.org/api/sqladmin/v1" ) var ( createInstanceToolKind = "cloud-sql-postgres-create-instance" ) type createInstanceTransport struct { transport http.RoundTripper url *url.URL } func (t *createInstanceTransport) 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 masterHandler struct { t *testing.T } func (h *masterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ua := r.Header.Get("User-Agent") if !strings.Contains(ua, "genai-toolbox/") { h.t.Errorf("User-Agent header not found in %q", ua) } var body sqladmin.DatabaseInstance if err := json.NewDecoder(r.Body).Decode(&body); err != nil { h.t.Fatalf("failed to decode request body: %v", err) } instanceName := body.Name if instanceName == "" { http.Error(w, "missing instance name", http.StatusBadRequest) return } var expectedBody sqladmin.DatabaseInstance var response any var statusCode int switch instanceName { case "instance1": expectedBody = sqladmin.DatabaseInstance{ Project: "p1", Name: "instance1", DatabaseVersion: "POSTGRES_15", RootPassword: "password123", Settings: &sqladmin.Settings{ AvailabilityType: "REGIONAL", Edition: "ENTERPRISE_PLUS", Tier: "db-perf-optimized-N-8", DataDiskSizeGb: 250, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op1", "status": "PENDING"} statusCode = http.StatusOK case "instance2": expectedBody = sqladmin.DatabaseInstance{ Project: "p2", Name: "instance2", DatabaseVersion: "POSTGRES_17", RootPassword: "password456", Settings: &sqladmin.Settings{ AvailabilityType: "ZONAL", Edition: "ENTERPRISE_PLUS", Tier: "db-perf-optimized-N-2", DataDiskSizeGb: 100, DataDiskType: "PD_SSD", }, } response = map[string]any{"name": "op2", "status": "RUNNING"} statusCode = http.StatusOK default: http.Error(w, fmt.Sprintf("unhandled instance name: %s", instanceName), http.StatusInternalServerError) return } if expectedBody.Project != body.Project { h.t.Errorf("unexpected project: got %q, want %q", body.Project, expectedBody.Project) } if expectedBody.Name != body.Name { h.t.Errorf("unexpected name: got %q, want %q", body.Name, expectedBody.Name) } if expectedBody.DatabaseVersion != body.DatabaseVersion { h.t.Errorf("unexpected databaseVersion: got %q, want %q", body.DatabaseVersion, expectedBody.DatabaseVersion) } if expectedBody.RootPassword != body.RootPassword { h.t.Errorf("unexpected rootPassword: got %q, want %q", body.RootPassword, expectedBody.RootPassword) } if diff := cmp.Diff(expectedBody.Settings, body.Settings); diff != "" { h.t.Errorf("unexpected request body settings (-want +got):\n%s", diff) } 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 TestCreateInstanceToolEndpoints(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() handler := &masterHandler{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 = &createInstanceTransport{ transport: originalTransport, url: serverURL, } t.Cleanup(func() { http.DefaultClient.Transport = originalTransport }) var args []string toolsFile := getCreateInstanceToolsConfig() 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, 10*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 creation - production", toolName: "create-instance-prod", body: `{"project": "p1", "name": "instance1", "databaseVersion": "POSTGRES_15", "rootPassword": "password123", "editionPreset": "Production"}`, want: `{"name":"op1","status":"PENDING"}`, }, { name: "successful creation - development", toolName: "create-instance-dev", body: `{"project": "p2", "name": "instance2", "rootPassword": "password456", "editionPreset": "Development"}`, want: `{"name":"op2","status":"RUNNING"}`, }, { name: "missing required parameter", toolName: "create-instance-prod", body: `{"name": "instance1"}`, 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 getCreateInstanceToolsConfig() 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{ "create-instance-prod": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, "create-instance-dev": map[string]any{ "kind": createInstanceToolKind, "source": "my-cloud-sql-source", }, }, } } ```