This is page 23 of 48. Use http://codebase.md/googleapis/genai-toolbox?lines=true&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 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package spannersql 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "strings" 21 | 22 | "cloud.google.com/go/spanner" 23 | yaml "github.com/goccy/go-yaml" 24 | "github.com/googleapis/genai-toolbox/internal/sources" 25 | spannerdb "github.com/googleapis/genai-toolbox/internal/sources/spanner" 26 | "github.com/googleapis/genai-toolbox/internal/tools" 27 | "google.golang.org/api/iterator" 28 | ) 29 | 30 | const kind string = "spanner-sql" 31 | 32 | func init() { 33 | if !tools.Register(kind, newConfig) { 34 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 35 | } 36 | } 37 | 38 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 39 | actual := Config{Name: name} 40 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 41 | return nil, err 42 | } 43 | return actual, nil 44 | } 45 | 46 | type compatibleSource interface { 47 | SpannerClient() *spanner.Client 48 | DatabaseDialect() string 49 | } 50 | 51 | // validate compatible sources are still compatible 52 | var _ compatibleSource = &spannerdb.Source{} 53 | 54 | var compatibleSources = [...]string{spannerdb.SourceKind} 55 | 56 | type Config struct { 57 | Name string `yaml:"name" validate:"required"` 58 | Kind string `yaml:"kind" validate:"required"` 59 | Source string `yaml:"source" validate:"required"` 60 | Description string `yaml:"description" validate:"required"` 61 | Statement string `yaml:"statement" validate:"required"` 62 | ReadOnly bool `yaml:"readOnly"` 63 | AuthRequired []string `yaml:"authRequired"` 64 | Parameters tools.Parameters `yaml:"parameters"` 65 | TemplateParameters tools.Parameters `yaml:"templateParameters"` 66 | } 67 | 68 | // validate interface 69 | var _ tools.ToolConfig = Config{} 70 | 71 | func (cfg Config) ToolConfigKind() string { 72 | return kind 73 | } 74 | 75 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 76 | // verify source exists 77 | rawS, ok := srcs[cfg.Source] 78 | if !ok { 79 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 80 | } 81 | 82 | // verify the source is compatible 83 | s, ok := rawS.(compatibleSource) 84 | if !ok { 85 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) 86 | } 87 | 88 | allParameters, paramManifest, err := tools.ProcessParameters(cfg.TemplateParameters, cfg.Parameters) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) 94 | 95 | // finish tool setup 96 | t := Tool{ 97 | Name: cfg.Name, 98 | Kind: kind, 99 | Parameters: cfg.Parameters, 100 | TemplateParameters: cfg.TemplateParameters, 101 | AllParams: allParameters, 102 | Statement: cfg.Statement, 103 | AuthRequired: cfg.AuthRequired, 104 | ReadOnly: cfg.ReadOnly, 105 | Client: s.SpannerClient(), 106 | dialect: s.DatabaseDialect(), 107 | manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, 108 | mcpManifest: mcpManifest, 109 | } 110 | return t, nil 111 | } 112 | 113 | // validate interface 114 | var _ tools.Tool = Tool{} 115 | 116 | type Tool struct { 117 | Name string `yaml:"name"` 118 | Kind string `yaml:"kind"` 119 | AuthRequired []string `yaml:"authRequired"` 120 | Parameters tools.Parameters `yaml:"parameters"` 121 | TemplateParameters tools.Parameters `yaml:"templateParameters"` 122 | AllParams tools.Parameters `yaml:"allParams"` 123 | ReadOnly bool `yaml:"readOnly"` 124 | Client *spanner.Client 125 | dialect string 126 | Statement string 127 | manifest tools.Manifest 128 | mcpManifest tools.McpManifest 129 | } 130 | 131 | func getMapParams(params tools.ParamValues, dialect string) (map[string]interface{}, error) { 132 | switch strings.ToLower(dialect) { 133 | case "googlesql": 134 | return params.AsMap(), nil 135 | case "postgresql": 136 | return params.AsMapByOrderedKeys(), nil 137 | default: 138 | return nil, fmt.Errorf("invalid dialect %s", dialect) 139 | } 140 | } 141 | 142 | // processRows iterates over the spanner.RowIterator and converts each row to a map[string]any. 143 | func processRows(iter *spanner.RowIterator) ([]any, error) { 144 | var out []any 145 | defer iter.Stop() 146 | 147 | for { 148 | row, err := iter.Next() 149 | if err == iterator.Done { 150 | break 151 | } 152 | if err != nil { 153 | return nil, fmt.Errorf("unable to parse row: %w", err) 154 | } 155 | 156 | vMap := make(map[string]any) 157 | cols := row.ColumnNames() 158 | for i, c := range cols { 159 | vMap[c] = row.ColumnValue(i) 160 | } 161 | out = append(out, vMap) 162 | } 163 | return out, nil 164 | } 165 | 166 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 167 | paramsMap := params.AsMap() 168 | newStatement, err := tools.ResolveTemplateParams(t.TemplateParameters, t.Statement, paramsMap) 169 | if err != nil { 170 | return nil, fmt.Errorf("unable to extract template params %w", err) 171 | } 172 | 173 | newParams, err := tools.GetParams(t.Parameters, paramsMap) 174 | if err != nil { 175 | return nil, fmt.Errorf("unable to extract standard params %w", err) 176 | } 177 | 178 | for i, p := range t.Parameters { 179 | name := p.GetName() 180 | value := newParams[i].Value 181 | 182 | // Spanner only accepts typed slices as input 183 | // This checks if the param is an array. 184 | // If yes, convert []any to typed slice (e.g []string, []int) 185 | switch arrayParam := p.(type) { 186 | case *tools.ArrayParameter: 187 | arrayParamValue, ok := value.([]any) 188 | if !ok { 189 | return nil, fmt.Errorf("unable to convert parameter `%s` to []any %w", name, err) 190 | } 191 | itemType := arrayParam.GetItems().GetType() 192 | var err error 193 | value, err = tools.ConvertAnySliceToTyped(arrayParamValue, itemType) 194 | if err != nil { 195 | return nil, fmt.Errorf("unable to convert parameter `%s` from []any to typed slice: %w", name, err) 196 | } 197 | } 198 | newParams[i] = tools.ParamValue{Name: name, Value: value} 199 | } 200 | 201 | mapParams, err := getMapParams(newParams, t.dialect) 202 | if err != nil { 203 | return nil, fmt.Errorf("fail to get map params: %w", err) 204 | } 205 | 206 | var results []any 207 | var opErr error 208 | stmt := spanner.Statement{ 209 | SQL: newStatement, 210 | Params: mapParams, 211 | } 212 | 213 | if t.ReadOnly { 214 | iter := t.Client.Single().Query(ctx, stmt) 215 | results, opErr = processRows(iter) 216 | } else { 217 | _, opErr = t.Client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { 218 | iter := txn.Query(ctx, stmt) 219 | results, err = processRows(iter) 220 | if err != nil { 221 | return err 222 | } 223 | return nil 224 | }) 225 | } 226 | 227 | if opErr != nil { 228 | return nil, fmt.Errorf("unable to execute client: %w", opErr) 229 | } 230 | 231 | return results, nil 232 | } 233 | 234 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 235 | return tools.ParseParams(t.AllParams, data, claims) 236 | } 237 | 238 | func (t Tool) Manifest() tools.Manifest { 239 | return t.manifest 240 | } 241 | 242 | func (t Tool) McpManifest() tools.McpManifest { 243 | return t.mcpManifest 244 | } 245 | 246 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 247 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 248 | } 249 | 250 | func (t Tool) RequiresClientAuthorization() bool { 251 | return false 252 | } 253 | ``` -------------------------------------------------------------------------------- /internal/tools/mysql/mysqllisttablefragmentation/mysqllisttablefragmentation.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package mysqllisttablefragmentation 16 | 17 | import ( 18 | "context" 19 | "database/sql" 20 | "fmt" 21 | 22 | yaml "github.com/goccy/go-yaml" 23 | "github.com/googleapis/genai-toolbox/internal/sources" 24 | "github.com/googleapis/genai-toolbox/internal/sources/cloudsqlmysql" 25 | "github.com/googleapis/genai-toolbox/internal/sources/mysql" 26 | "github.com/googleapis/genai-toolbox/internal/tools" 27 | "github.com/googleapis/genai-toolbox/internal/tools/mysql/mysqlcommon" 28 | "github.com/googleapis/genai-toolbox/internal/util" 29 | ) 30 | 31 | const kind string = "mysql-list-table-fragmentation" 32 | 33 | const listTableFragmentationStatement = ` 34 | SELECT 35 | table_schema, 36 | table_name, 37 | data_length AS data_size, 38 | index_length AS index_size, 39 | data_free AS data_free, 40 | ROUND((data_free / (data_length + index_length)) * 100, 2) AS fragmentation_percentage 41 | FROM 42 | information_schema.tables 43 | WHERE 44 | table_schema NOT IN ('sys', 'performance_schema', 'mysql', 'information_schema') 45 | AND (COALESCE(?, '') = '' OR table_schema = ?) 46 | AND (COALESCE(?, '') = '' OR table_name = ?) 47 | AND data_free >= ? 48 | ORDER BY 49 | fragmentation_percentage DESC, 50 | table_schema, 51 | table_name 52 | LIMIT ?; 53 | ` 54 | 55 | func init() { 56 | if !tools.Register(kind, newConfig) { 57 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 58 | } 59 | } 60 | 61 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 62 | actual := Config{Name: name} 63 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 64 | return nil, err 65 | } 66 | return actual, nil 67 | } 68 | 69 | type compatibleSource interface { 70 | MySQLPool() *sql.DB 71 | } 72 | 73 | // validate compatible sources are still compatible 74 | var _ compatibleSource = &mysql.Source{} 75 | var _ compatibleSource = &cloudsqlmysql.Source{} 76 | 77 | var compatibleSources = [...]string{mysql.SourceKind, cloudsqlmysql.SourceKind} 78 | 79 | type Config struct { 80 | Name string `yaml:"name" validate:"required"` 81 | Kind string `yaml:"kind" validate:"required"` 82 | Source string `yaml:"source" validate:"required"` 83 | Description string `yaml:"description" validate:"required"` 84 | AuthRequired []string `yaml:"authRequired"` 85 | } 86 | 87 | // validate interface 88 | var _ tools.ToolConfig = Config{} 89 | 90 | func (cfg Config) ToolConfigKind() string { 91 | return kind 92 | } 93 | 94 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 95 | // verify source exists 96 | rawS, ok := srcs[cfg.Source] 97 | if !ok { 98 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 99 | } 100 | 101 | // verify the source is compatible 102 | s, ok := rawS.(compatibleSource) 103 | if !ok { 104 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) 105 | } 106 | 107 | allParameters := tools.Parameters{ 108 | 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"), 109 | tools.NewStringParameterWithDefault("table_name", "", "(Optional) Name of the table to be checked. Check all tables visible to the current user if not specified."), 110 | tools.NewIntParameterWithDefault("data_free_threshold_bytes", 1, "(Optional) Only show tables with at least this much free space in bytes. Default is 1"), 111 | tools.NewIntParameterWithDefault("limit", 10, "(Optional) Max rows to return, default is 10"), 112 | } 113 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) 114 | 115 | // finish tool setup 116 | t := Tool{ 117 | Name: cfg.Name, 118 | Kind: kind, 119 | AuthRequired: cfg.AuthRequired, 120 | Pool: s.MySQLPool(), 121 | allParams: allParameters, 122 | manifest: tools.Manifest{Description: cfg.Description, Parameters: allParameters.Manifest(), AuthRequired: cfg.AuthRequired}, 123 | mcpManifest: mcpManifest, 124 | } 125 | return t, nil 126 | } 127 | 128 | // validate interface 129 | var _ tools.Tool = Tool{} 130 | 131 | type Tool struct { 132 | Name string `yaml:"name"` 133 | Kind string `yaml:"kind"` 134 | AuthRequired []string `yaml:"authRequired"` 135 | allParams tools.Parameters `yaml:"parameters"` 136 | Pool *sql.DB 137 | manifest tools.Manifest 138 | mcpManifest tools.McpManifest 139 | } 140 | 141 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 142 | paramsMap := params.AsMap() 143 | 144 | table_schema, ok := paramsMap["table_schema"].(string) 145 | if !ok { 146 | return nil, fmt.Errorf("invalid 'table_schema' parameter; expected a string") 147 | } 148 | table_name, ok := paramsMap["table_name"].(string) 149 | if !ok { 150 | return nil, fmt.Errorf("invalid 'table_name' parameter; expected a string") 151 | } 152 | data_free_threshold_bytes, ok := paramsMap["data_free_threshold_bytes"].(int) 153 | if !ok { 154 | return nil, fmt.Errorf("invalid 'data_free_threshold_bytes' parameter; expected an integer") 155 | } 156 | limit, ok := paramsMap["limit"].(int) 157 | if !ok { 158 | return nil, fmt.Errorf("invalid 'limit' parameter; expected an integer") 159 | } 160 | 161 | // Log the query executed for debugging. 162 | logger, err := util.LoggerFromContext(ctx) 163 | if err != nil { 164 | return nil, fmt.Errorf("error getting logger: %s", err) 165 | } 166 | logger.DebugContext(ctx, "executing `%s` tool query: %s", kind, listTableFragmentationStatement) 167 | 168 | results, err := t.Pool.QueryContext(ctx, listTableFragmentationStatement, table_schema, table_schema, table_name, table_name, data_free_threshold_bytes, limit) 169 | if err != nil { 170 | return nil, fmt.Errorf("unable to execute query: %w", err) 171 | } 172 | defer results.Close() 173 | 174 | cols, err := results.Columns() 175 | if err != nil { 176 | return nil, fmt.Errorf("unable to retrieve rows column name: %w", err) 177 | } 178 | 179 | // create an array of values for each column, which can be re-used to scan each row 180 | rawValues := make([]any, len(cols)) 181 | values := make([]any, len(cols)) 182 | for i := range rawValues { 183 | values[i] = &rawValues[i] 184 | } 185 | 186 | colTypes, err := results.ColumnTypes() 187 | if err != nil { 188 | return nil, fmt.Errorf("unable to get column types: %w", err) 189 | } 190 | 191 | var out []any 192 | for results.Next() { 193 | err := results.Scan(values...) 194 | if err != nil { 195 | return nil, fmt.Errorf("unable to parse row: %w", err) 196 | } 197 | vMap := make(map[string]any) 198 | for i, name := range cols { 199 | val := rawValues[i] 200 | if val == nil { 201 | vMap[name] = nil 202 | continue 203 | } 204 | 205 | vMap[name], err = mysqlcommon.ConvertToType(colTypes[i], val) 206 | if err != nil { 207 | return nil, fmt.Errorf("errors encountered when converting values: %w", err) 208 | } 209 | } 210 | out = append(out, vMap) 211 | } 212 | 213 | if err := results.Err(); err != nil { 214 | return nil, fmt.Errorf("errors encountered during row iteration: %w", err) 215 | } 216 | 217 | return out, nil 218 | } 219 | 220 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 221 | return tools.ParseParams(t.allParams, data, claims) 222 | } 223 | 224 | func (t Tool) Manifest() tools.Manifest { 225 | return t.manifest 226 | } 227 | 228 | func (t Tool) McpManifest() tools.McpManifest { 229 | return t.mcpManifest 230 | } 231 | 232 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 233 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 234 | } 235 | 236 | func (t Tool) RequiresClientAuthorization() bool { 237 | return false 238 | } 239 | ``` -------------------------------------------------------------------------------- /internal/server/static/js/loadTools.js: -------------------------------------------------------------------------------- ```javascript 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { renderToolInterface } from "./toolDisplay.js"; 16 | 17 | let toolDetailsAbortController = null; 18 | 19 | /** 20 | * Fetches a toolset from the /api/toolset endpoint and initiates creating the tool list. 21 | * @param {!HTMLElement} secondNavContent The HTML element where the tool list will be rendered. 22 | * @param {!HTMLElement} toolDisplayArea The HTML element where the details of a selected tool will be displayed. 23 | * @param {string} toolsetName The name of the toolset to load (empty string loads all tools). 24 | * @returns {!Promise<void>} A promise that resolves when the tools are loaded and rendered, or rejects on error. 25 | */ 26 | export async function loadTools(secondNavContent, toolDisplayArea, toolsetName) { 27 | secondNavContent.innerHTML = '<p>Fetching tools...</p>'; 28 | try { 29 | const response = await fetch(`/api/toolset/${toolsetName}`); 30 | if (!response.ok) { 31 | throw new Error(`HTTP error! status: ${response.status}`); 32 | } 33 | const apiResponse = await response.json(); 34 | renderToolList(apiResponse, secondNavContent, toolDisplayArea); 35 | } catch (error) { 36 | console.error('Failed to load tools:', error); 37 | secondNavContent.innerHTML = `<p class="error">Failed to load tools: <pre><code>${error}</code></pre></p>`; 38 | } 39 | } 40 | 41 | /** 42 | * Renders the list of tools as buttons within the provided HTML element. 43 | * @param {?{tools: ?Object<string,*>} } apiResponse The API response object containing the tools. 44 | * @param {!HTMLElement} secondNavContent The HTML element to render the tool list into. 45 | * @param {!HTMLElement} toolDisplayArea The HTML element for displaying tool details (passed to event handlers). 46 | */ 47 | function renderToolList(apiResponse, secondNavContent, toolDisplayArea) { 48 | secondNavContent.innerHTML = ''; 49 | 50 | if (!apiResponse || typeof apiResponse.tools !== 'object' || apiResponse.tools === null) { 51 | console.error('Error: Expected an object with a "tools" property, but received:', apiResponse); 52 | secondNavContent.textContent = 'Error: Invalid response format from toolset API.'; 53 | return; 54 | } 55 | 56 | const toolsObject = apiResponse.tools; 57 | const toolNames = Object.keys(toolsObject); 58 | 59 | if (toolNames.length === 0) { 60 | secondNavContent.textContent = 'No tools found.'; 61 | return; 62 | } 63 | 64 | const ul = document.createElement('ul'); 65 | toolNames.forEach(toolName => { 66 | const li = document.createElement('li'); 67 | const button = document.createElement('button'); 68 | button.textContent = toolName; 69 | button.dataset.toolname = toolName; 70 | button.classList.add('tool-button'); 71 | button.addEventListener('click', (event) => handleToolClick(event, secondNavContent, toolDisplayArea)); 72 | li.appendChild(button); 73 | ul.appendChild(li); 74 | }); 75 | secondNavContent.appendChild(ul); 76 | } 77 | 78 | /** 79 | * Handles the click event on a tool button. 80 | * @param {!Event} event The click event object. 81 | * @param {!HTMLElement} secondNavContent The parent element containing the tool buttons. 82 | * @param {!HTMLElement} toolDisplayArea The HTML element where tool details will be shown. 83 | */ 84 | function handleToolClick(event, secondNavContent, toolDisplayArea) { 85 | const toolName = event.target.dataset.toolname; 86 | if (toolName) { 87 | const currentActive = secondNavContent.querySelector('.tool-button.active'); 88 | if (currentActive) { 89 | currentActive.classList.remove('active'); 90 | } 91 | event.target.classList.add('active'); 92 | fetchToolDetails(toolName, toolDisplayArea); 93 | } 94 | } 95 | 96 | /** 97 | * Fetches details for a specific tool /api/tool endpoint. 98 | * It aborts any previous in-flight request for tool details to stop race condition. 99 | * @param {string} toolName The name of the tool to fetch details for. 100 | * @param {!HTMLElement} toolDisplayArea The HTML element to display the tool interface in. 101 | * @returns {!Promise<void>} A promise that resolves when the tool details are fetched and rendered, or rejects on error. 102 | */ 103 | async function fetchToolDetails(toolName, toolDisplayArea) { 104 | if (toolDetailsAbortController) { 105 | toolDetailsAbortController.abort(); 106 | console.debug("Aborted previous tool fetch."); 107 | } 108 | 109 | toolDetailsAbortController = new AbortController(); 110 | const signal = toolDetailsAbortController.signal; 111 | 112 | toolDisplayArea.innerHTML = '<p>Loading tool details...</p>'; 113 | 114 | try { 115 | const response = await fetch(`/api/tool/${encodeURIComponent(toolName)}`, { signal }); 116 | if (!response.ok) { 117 | throw new Error(`HTTP error! status: ${response.status}`); 118 | } 119 | const apiResponse = await response.json(); 120 | 121 | if (!apiResponse.tools || !apiResponse.tools[toolName]) { 122 | throw new Error(`Tool "${toolName}" data not found in API response.`); 123 | } 124 | const toolObject = apiResponse.tools[toolName]; 125 | console.debug("Received tool object: ", toolObject) 126 | 127 | const toolInterfaceData = { 128 | id: toolName, 129 | name: toolName, 130 | description: toolObject.description || "No description provided.", 131 | authRequired: toolObject.authRequired || [], 132 | parameters: (toolObject.parameters || []).map(param => { 133 | let inputType = 'text'; 134 | const apiType = param.type ? param.type.toLowerCase() : 'string'; 135 | let valueType = 'string'; 136 | let label = param.description || param.name; 137 | 138 | if (apiType === 'integer' || apiType === 'float') { 139 | inputType = 'number'; 140 | valueType = 'number'; 141 | } else if (apiType === 'boolean') { 142 | inputType = 'checkbox'; 143 | valueType = 'boolean'; 144 | } else if (apiType === 'array') { 145 | inputType = 'textarea'; 146 | const itemType = param.items && param.items.type ? param.items.type.toLowerCase() : 'string'; 147 | valueType = `array<${itemType}>`; 148 | label += ' (Array)'; 149 | } 150 | 151 | return { 152 | name: param.name, 153 | type: inputType, 154 | valueType: valueType, 155 | label: label, 156 | authServices: param.authSources, 157 | required: param.required || false, 158 | // defaultValue: param.default, can't do this yet bc tool manifest doesn't have default 159 | }; 160 | }) 161 | }; 162 | 163 | console.debug("Transformed toolInterfaceData:", toolInterfaceData); 164 | 165 | renderToolInterface(toolInterfaceData, toolDisplayArea); 166 | } catch (error) { 167 | if (error.name === 'AbortError') { 168 | console.debug("Previous fetch was aborted, expected behavior."); 169 | } else { 170 | console.error(`Failed to load details for tool "${toolName}":`, error); 171 | toolDisplayArea.innerHTML = `<p class="error">Failed to load details for ${toolName}. ${error.message}</p>`; 172 | } 173 | } 174 | } ``` -------------------------------------------------------------------------------- /internal/sources/yugabytedb/yugabytedb_test.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package yugabytedb_test 16 | 17 | import ( 18 | "testing" 19 | 20 | "strings" 21 | 22 | yaml "github.com/goccy/go-yaml" 23 | "github.com/google/go-cmp/cmp" 24 | "github.com/googleapis/genai-toolbox/internal/server" 25 | "github.com/googleapis/genai-toolbox/internal/sources/yugabytedb" 26 | "github.com/googleapis/genai-toolbox/internal/testutils" 27 | ) 28 | 29 | // Basic config parse 30 | func TestParseFromYamlYugabyteDB(t *testing.T) { 31 | tcs := []struct { 32 | desc string 33 | in string 34 | want server.SourceConfigs 35 | }{ 36 | { 37 | desc: "only required fields", 38 | in: ` 39 | sources: 40 | my-yb-instance: 41 | kind: yugabytedb 42 | name: my-yb-instance 43 | host: yb-host 44 | port: yb-port 45 | user: yb_user 46 | password: yb_pass 47 | database: yb_db 48 | `, 49 | want: server.SourceConfigs{ 50 | "my-yb-instance": yugabytedb.Config{ 51 | Name: "my-yb-instance", 52 | Kind: "yugabytedb", 53 | Host: "yb-host", 54 | Port: "yb-port", 55 | User: "yb_user", 56 | Password: "yb_pass", 57 | Database: "yb_db", 58 | }, 59 | }, 60 | }, 61 | { 62 | desc: "with loadBalance only", 63 | in: ` 64 | sources: 65 | my-yb-instance: 66 | kind: yugabytedb 67 | name: my-yb-instance 68 | host: yb-host 69 | port: yb-port 70 | user: yb_user 71 | password: yb_pass 72 | database: yb_db 73 | loadBalance: true 74 | `, 75 | want: server.SourceConfigs{ 76 | "my-yb-instance": yugabytedb.Config{ 77 | Name: "my-yb-instance", 78 | Kind: "yugabytedb", 79 | Host: "yb-host", 80 | Port: "yb-port", 81 | User: "yb_user", 82 | Password: "yb_pass", 83 | Database: "yb_db", 84 | LoadBalance: "true", 85 | }, 86 | }, 87 | }, 88 | { 89 | desc: "loadBalance with topologyKeys", 90 | in: ` 91 | sources: 92 | my-yb-instance: 93 | kind: yugabytedb 94 | name: my-yb-instance 95 | host: yb-host 96 | port: yb-port 97 | user: yb_user 98 | password: yb_pass 99 | database: yb_db 100 | loadBalance: true 101 | topologyKeys: zone1,zone2 102 | `, 103 | want: server.SourceConfigs{ 104 | "my-yb-instance": yugabytedb.Config{ 105 | Name: "my-yb-instance", 106 | Kind: "yugabytedb", 107 | Host: "yb-host", 108 | Port: "yb-port", 109 | User: "yb_user", 110 | Password: "yb_pass", 111 | Database: "yb_db", 112 | LoadBalance: "true", 113 | TopologyKeys: "zone1,zone2", 114 | }, 115 | }, 116 | }, 117 | { 118 | desc: "with fallback only", 119 | in: ` 120 | sources: 121 | my-yb-instance: 122 | kind: yugabytedb 123 | name: my-yb-instance 124 | host: yb-host 125 | port: yb-port 126 | user: yb_user 127 | password: yb_pass 128 | database: yb_db 129 | loadBalance: true 130 | topologyKeys: zone1 131 | fallbackToTopologyKeysOnly: true 132 | `, 133 | want: server.SourceConfigs{ 134 | "my-yb-instance": yugabytedb.Config{ 135 | Name: "my-yb-instance", 136 | Kind: "yugabytedb", 137 | Host: "yb-host", 138 | Port: "yb-port", 139 | User: "yb_user", 140 | Password: "yb_pass", 141 | Database: "yb_db", 142 | LoadBalance: "true", 143 | TopologyKeys: "zone1", 144 | FallBackToTopologyKeysOnly: "true", 145 | }, 146 | }, 147 | }, 148 | { 149 | desc: "with refresh interval and reconnect delay", 150 | in: ` 151 | sources: 152 | my-yb-instance: 153 | kind: yugabytedb 154 | name: my-yb-instance 155 | host: yb-host 156 | port: yb-port 157 | user: yb_user 158 | password: yb_pass 159 | database: yb_db 160 | loadBalance: true 161 | ybServersRefreshInterval: 20 162 | failedHostReconnectDelaySecs: 5 163 | `, 164 | want: server.SourceConfigs{ 165 | "my-yb-instance": yugabytedb.Config{ 166 | Name: "my-yb-instance", 167 | Kind: "yugabytedb", 168 | Host: "yb-host", 169 | Port: "yb-port", 170 | User: "yb_user", 171 | Password: "yb_pass", 172 | Database: "yb_db", 173 | LoadBalance: "true", 174 | YBServersRefreshInterval: "20", 175 | FailedHostReconnectDelaySeconds: "5", 176 | }, 177 | }, 178 | }, 179 | { 180 | desc: "all fields set", 181 | in: ` 182 | sources: 183 | my-yb-instance: 184 | kind: yugabytedb 185 | name: my-yb-instance 186 | host: yb-host 187 | port: yb-port 188 | user: yb_user 189 | password: yb_pass 190 | database: yb_db 191 | loadBalance: true 192 | topologyKeys: zone1,zone2 193 | fallbackToTopologyKeysOnly: true 194 | ybServersRefreshInterval: 30 195 | failedHostReconnectDelaySecs: 10 196 | `, 197 | want: server.SourceConfigs{ 198 | "my-yb-instance": yugabytedb.Config{ 199 | Name: "my-yb-instance", 200 | Kind: "yugabytedb", 201 | Host: "yb-host", 202 | Port: "yb-port", 203 | User: "yb_user", 204 | Password: "yb_pass", 205 | Database: "yb_db", 206 | LoadBalance: "true", 207 | TopologyKeys: "zone1,zone2", 208 | FallBackToTopologyKeysOnly: "true", 209 | YBServersRefreshInterval: "30", 210 | FailedHostReconnectDelaySeconds: "10", 211 | }, 212 | }, 213 | }, 214 | } 215 | 216 | for _, tc := range tcs { 217 | t.Run(tc.desc, func(t *testing.T) { 218 | got := struct { 219 | Sources server.SourceConfigs `yaml:"sources"` 220 | }{} 221 | 222 | err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) 223 | if err != nil { 224 | t.Fatalf("unable to unmarshal: %s", err) 225 | } 226 | if !cmp.Equal(tc.want, got.Sources) { 227 | t.Fatalf("incorrect parse (-want +got):\n%s", cmp.Diff(tc.want, got.Sources)) 228 | } 229 | }) 230 | } 231 | } 232 | 233 | func TestFailParseFromYamlYugabyteDB(t *testing.T) { 234 | tcs := []struct { 235 | desc string 236 | in string 237 | err string 238 | }{ 239 | { 240 | desc: "extra field", 241 | in: ` 242 | sources: 243 | my-yb-source: 244 | kind: yugabytedb 245 | name: my-yb-source 246 | host: yb-host 247 | port: yb-port 248 | database: yb_db 249 | user: yb_user 250 | password: yb_pass 251 | foo: bar 252 | `, 253 | err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": [2:1] unknown field \"foo\"", 254 | }, 255 | { 256 | desc: "missing required field (password)", 257 | in: ` 258 | sources: 259 | my-yb-source: 260 | kind: yugabytedb 261 | name: my-yb-source 262 | host: yb-host 263 | port: yb-port 264 | database: yb_db 265 | user: yb_user 266 | `, 267 | err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Password' Error:Field validation for 'Password' failed on the 'required' tag", 268 | }, 269 | { 270 | desc: "missing required field (host)", 271 | in: ` 272 | sources: 273 | my-yb-source: 274 | kind: yugabytedb 275 | name: my-yb-source 276 | port: yb-port 277 | database: yb_db 278 | user: yb_user 279 | password: yb_pass 280 | `, 281 | err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Host' Error:Field validation for 'Host' failed on the 'required' tag", 282 | }, 283 | } 284 | for _, tc := range tcs { 285 | t.Run(tc.desc, func(t *testing.T) { 286 | got := struct { 287 | Sources server.SourceConfigs `yaml:"sources"` 288 | }{} 289 | err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got) 290 | if err == nil { 291 | t.Fatalf("expected parsing to fail") 292 | } 293 | errStr := err.Error() 294 | if !strings.Contains(errStr, tc.err) { 295 | t.Fatalf("unexpected error:\nGot: %q\nWant: %q", errStr, tc.err) 296 | } 297 | }) 298 | } 299 | } 300 | ``` -------------------------------------------------------------------------------- /internal/tools/mongodb/mongodbfind/mongodbfind.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package mongodbfind 15 | 16 | import ( 17 | "context" 18 | "encoding/json" 19 | "fmt" 20 | "slices" 21 | 22 | "github.com/goccy/go-yaml" 23 | mongosrc "github.com/googleapis/genai-toolbox/internal/sources/mongodb" 24 | "github.com/googleapis/genai-toolbox/internal/util" 25 | "go.mongodb.org/mongo-driver/bson" 26 | "go.mongodb.org/mongo-driver/mongo" 27 | "go.mongodb.org/mongo-driver/mongo/options" 28 | 29 | "github.com/googleapis/genai-toolbox/internal/sources" 30 | "github.com/googleapis/genai-toolbox/internal/tools" 31 | ) 32 | 33 | const kind string = "mongodb-find" 34 | 35 | func init() { 36 | if !tools.Register(kind, newConfig) { 37 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 38 | } 39 | } 40 | 41 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 42 | actual := Config{Name: name} 43 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 44 | return nil, err 45 | } 46 | return actual, nil 47 | } 48 | 49 | type Config struct { 50 | Name string `yaml:"name" validate:"required"` 51 | Kind string `yaml:"kind" validate:"required"` 52 | Source string `yaml:"source" validate:"required"` 53 | AuthRequired []string `yaml:"authRequired" validate:"required"` 54 | Description string `yaml:"description" validate:"required"` 55 | Database string `yaml:"database" validate:"required"` 56 | Collection string `yaml:"collection" validate:"required"` 57 | FilterPayload string `yaml:"filterPayload" validate:"required"` 58 | FilterParams tools.Parameters `yaml:"filterParams"` 59 | ProjectPayload string `yaml:"projectPayload"` 60 | ProjectParams tools.Parameters `yaml:"projectParams"` 61 | SortPayload string `yaml:"sortPayload"` 62 | SortParams tools.Parameters `yaml:"sortParams"` 63 | Limit int64 `yaml:"limit"` 64 | } 65 | 66 | // validate interface 67 | var _ tools.ToolConfig = Config{} 68 | 69 | func (cfg Config) ToolConfigKind() string { 70 | return kind 71 | } 72 | 73 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 74 | // verify source exists 75 | rawS, ok := srcs[cfg.Source] 76 | if !ok { 77 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 78 | } 79 | 80 | // verify the source is compatible 81 | s, ok := rawS.(*mongosrc.Source) 82 | if !ok { 83 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be `mongodb`", kind) 84 | } 85 | 86 | // Create a slice for all parameters 87 | allParameters := slices.Concat(cfg.FilterParams, cfg.ProjectParams, cfg.SortParams) 88 | 89 | // Verify no duplicate parameter names 90 | err := tools.CheckDuplicateParameters(allParameters) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | // Verify 'limit' value 96 | if cfg.Limit <= 0 { 97 | return nil, fmt.Errorf("limit must be a positive number, but got %d", cfg.Limit) 98 | } 99 | 100 | // Create Toolbox manifest 101 | paramManifest := allParameters.Manifest() 102 | if paramManifest == nil { 103 | paramManifest = make([]tools.ParameterManifest, 0) 104 | } 105 | 106 | // Create MCP manifest 107 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) 108 | 109 | // finish tool setup 110 | return Tool{ 111 | Name: cfg.Name, 112 | Kind: kind, 113 | AuthRequired: cfg.AuthRequired, 114 | Collection: cfg.Collection, 115 | FilterPayload: cfg.FilterPayload, 116 | FilterParams: cfg.FilterParams, 117 | ProjectPayload: cfg.ProjectPayload, 118 | ProjectParams: cfg.ProjectParams, 119 | SortPayload: cfg.SortPayload, 120 | SortParams: cfg.SortParams, 121 | Limit: cfg.Limit, 122 | AllParams: allParameters, 123 | database: s.Client.Database(cfg.Database), 124 | manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, 125 | mcpManifest: mcpManifest, 126 | }, nil 127 | } 128 | 129 | // validate interface 130 | var _ tools.Tool = Tool{} 131 | 132 | type Tool struct { 133 | Name string `yaml:"name"` 134 | Kind string `yaml:"kind"` 135 | Description string `yaml:"description"` 136 | AuthRequired []string `yaml:"authRequired"` 137 | Collection string `yaml:"collection"` 138 | FilterPayload string `yaml:"filterPayload"` 139 | FilterParams tools.Parameters `yaml:"filterParams"` 140 | ProjectPayload string `yaml:"projectPayload"` 141 | ProjectParams tools.Parameters `yaml:"projectParams"` 142 | SortPayload string `yaml:"sortPayload"` 143 | SortParams tools.Parameters `yaml:"sortParams"` 144 | Limit int64 `yaml:"limit"` 145 | AllParams tools.Parameters `yaml:"allParams"` 146 | 147 | database *mongo.Database 148 | manifest tools.Manifest 149 | mcpManifest tools.McpManifest 150 | } 151 | 152 | func getOptions(ctx context.Context, sortParameters tools.Parameters, projectPayload string, limit int64, paramsMap map[string]any) (*options.FindOptions, error) { 153 | logger, err := util.LoggerFromContext(ctx) 154 | if err != nil { 155 | panic(err) 156 | } 157 | 158 | opts := options.Find() 159 | 160 | sort := bson.M{} 161 | for _, p := range sortParameters { 162 | sort[p.GetName()] = paramsMap[p.GetName()] 163 | } 164 | opts = opts.SetSort(sort) 165 | 166 | if len(projectPayload) > 0 { 167 | 168 | result, err := tools.PopulateTemplateWithJSON("MongoDBFindProjectString", projectPayload, paramsMap) 169 | 170 | if err != nil { 171 | return nil, fmt.Errorf("error populating project payload: %s", err) 172 | } 173 | 174 | var projection any 175 | err = bson.UnmarshalExtJSON([]byte(result), false, &projection) 176 | if err != nil { 177 | return nil, fmt.Errorf("error unmarshalling projection: %s", err) 178 | } 179 | 180 | opts = opts.SetProjection(projection) 181 | logger.DebugContext(ctx, "Projection is set to %v", projection) 182 | } 183 | 184 | if limit > 0 { 185 | opts = opts.SetLimit(limit) 186 | logger.DebugContext(ctx, "Limit is being set to %d", limit) 187 | } 188 | return opts, nil 189 | } 190 | 191 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 192 | paramsMap := params.AsMap() 193 | 194 | filterString, err := tools.PopulateTemplateWithJSON("MongoDBFindFilterString", t.FilterPayload, paramsMap) 195 | 196 | if err != nil { 197 | return nil, fmt.Errorf("error populating filter: %s", err) 198 | } 199 | 200 | opts, err := getOptions(ctx, t.SortParams, t.ProjectPayload, t.Limit, paramsMap) 201 | if err != nil { 202 | return nil, fmt.Errorf("error populating options: %s", err) 203 | } 204 | 205 | var filter = bson.D{} 206 | err = bson.UnmarshalExtJSON([]byte(filterString), false, &filter) 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | cur, err := t.database.Collection(t.Collection).Find(ctx, filter, opts) 212 | if err != nil { 213 | return nil, err 214 | } 215 | defer cur.Close(ctx) 216 | 217 | var data = []any{} 218 | err = cur.All(context.TODO(), &data) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | var final []any 224 | for _, item := range data { 225 | tmp, _ := bson.MarshalExtJSON(item, false, false) 226 | var tmp2 any 227 | err = json.Unmarshal(tmp, &tmp2) 228 | if err != nil { 229 | return nil, err 230 | } 231 | final = append(final, tmp2) 232 | } 233 | 234 | return final, err 235 | } 236 | 237 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 238 | return tools.ParseParams(t.AllParams, data, claims) 239 | } 240 | 241 | func (t Tool) Manifest() tools.Manifest { 242 | return t.manifest 243 | } 244 | 245 | func (t Tool) McpManifest() tools.McpManifest { 246 | return t.mcpManifest 247 | } 248 | 249 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 250 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 251 | } 252 | 253 | func (t Tool) RequiresClientAuthorization() bool { 254 | return false 255 | } 256 | ``` -------------------------------------------------------------------------------- /internal/server/static/js/auth.js: -------------------------------------------------------------------------------- ```javascript 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /** 16 | * Renders the Google Sign-In button using the GIS library. 17 | * @param {string} toolId The ID of the tool. 18 | * @param {string} clientId The Google OAuth Client ID. 19 | * @param {string} authProfileName The name of the auth service in tools file. 20 | */ 21 | function renderGoogleSignInButton(toolId, clientId, authProfileName) { 22 | const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; 23 | const GIS_CONTAINER_ID = `gisContainer-${UNIQUE_ID_BASE}`; 24 | const gisContainer = document.getElementById(GIS_CONTAINER_ID); 25 | const setupGisBtn = document.querySelector(`#google-auth-details-${UNIQUE_ID_BASE} .btn--setup-gis`); 26 | 27 | if (!gisContainer) { 28 | console.error('GIS container not found:', GIS_CONTAINER_ID); 29 | return; 30 | } 31 | 32 | if (!clientId) { 33 | alert('Please enter an OAuth Client ID first.'); 34 | return; 35 | } 36 | 37 | // hide the setup button and show the container for the GIS button 38 | if (setupGisBtn) setupGisBtn.style.display = 'none'; 39 | gisContainer.innerHTML = ''; 40 | gisContainer.style.display = 'flex'; 41 | if (window.google && window.google.accounts && window.google.accounts.id) { 42 | try { 43 | const handleResponse = (response) => handleCredentialResponse(response, toolId, authProfileName); 44 | window.google.accounts.id.initialize({ 45 | client_id: clientId, 46 | callback: handleResponse, 47 | auto_select: false 48 | }); 49 | window.google.accounts.id.renderButton( 50 | gisContainer, 51 | { theme: "outline", size: "large", text: "signin_with" } 52 | ); 53 | } catch (error) { 54 | console.error("Error initializing Google Sign-In:", error); 55 | alert("Error initializing Google Sign-In. Check the Client ID and browser console."); 56 | gisContainer.innerHTML = '<p style="color: red;">Error loading Sign-In button.</p>'; 57 | if (setupGisBtn) setupGisBtn.style.display = ''; 58 | } 59 | } else { 60 | console.error("GIS library not fully loaded yet."); 61 | alert("Google Identity Services library not ready. Please try again in a moment."); 62 | gisContainer.innerHTML = '<p style="color: red;">GIS library not ready.</p>'; 63 | if (setupGisBtn) setupGisBtn.style.display = ''; 64 | } 65 | } 66 | 67 | /** 68 | * Handles the credential response from the Google Sign-In library. 69 | * @param {!CredentialResponse} response The credential response object from GIS. 70 | * @param {string} toolId The ID of the tool. 71 | * @param {string} authProfileName The name of the auth service in tools file. 72 | */ 73 | function handleCredentialResponse(response, toolId, authProfileName) { 74 | console.debug("handleCredentialResponse called with:", { response, toolId, authProfileName }); 75 | const headersTextarea = document.getElementById(`headers-textarea-${toolId}`); 76 | if (!headersTextarea) { 77 | console.error('Headers textarea not found for toolId:', toolId); 78 | return; 79 | } 80 | 81 | const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; 82 | const setupGisBtn = document.querySelector(`#google-auth-details-${UNIQUE_ID_BASE} .setup-gis-btn`); 83 | const gisContainer = document.getElementById(`gisContainer-${UNIQUE_ID_BASE}`); 84 | 85 | if (response.credential) { 86 | const idToken = response.credential; 87 | 88 | try { 89 | let currentHeaders = {}; 90 | if (headersTextarea.value) { 91 | currentHeaders = JSON.parse(headersTextarea.value); 92 | } 93 | const HEADER_KEY = `${authProfileName}_token`; 94 | currentHeaders[HEADER_KEY] = `${idToken}`; 95 | headersTextarea.value = JSON.stringify(currentHeaders, null, 2); 96 | 97 | if (gisContainer) gisContainer.style.display = 'none'; 98 | if (setupGisBtn) setupGisBtn.style.display = ''; 99 | 100 | } catch (e) { 101 | alert('Headers are not valid JSON. Please correct and try again.'); 102 | console.error("Header JSON parse error:", e); 103 | } 104 | } else { 105 | console.error("Error: No credential in response", response); 106 | alert('Error: No ID Token received. Check console for details.'); 107 | 108 | if (gisContainer) gisContainer.style.display = 'none'; 109 | if (setupGisBtn) setupGisBtn.style.display = ''; 110 | } 111 | } 112 | 113 | // creates the Google Auth method dropdown 114 | export function createGoogleAuthMethodItem(toolId, authProfileName) { 115 | const UNIQUE_ID_BASE = `${toolId}-${authProfileName}`; 116 | const item = document.createElement('div'); 117 | 118 | item.className = 'auth-method-item'; 119 | item.innerHTML = ` 120 | <div class="auth-method-header"> 121 | <span class="auth-method-label">Google ID Token (${authProfileName})</span> 122 | <button class="toggle-details-tab">Auto Setup</button> 123 | </div> 124 | <div class="auth-method-details" id="google-auth-details-${UNIQUE_ID_BASE}" style="display: none;"> 125 | <div class="auth-controls"> 126 | <div class="auth-input-row"> 127 | <label for="clientIdInput-${UNIQUE_ID_BASE}">OAuth Client ID:</label> 128 | <input type="text" id="clientIdInput-${UNIQUE_ID_BASE}" placeholder="Enter Client ID" class="auth-input"> 129 | </div> 130 | <div class="auth-instructions"> 131 | <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, 132 | navigate to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> for this setting. 133 | </div> 134 | <div class="auth-method-actions"> 135 | <button class="btn btn--setup-gis">Continue</button> 136 | <div id="gisContainer-${UNIQUE_ID_BASE}" class="auth-interactive-element gis-container" style="display: none;"></div> 137 | </div> 138 | </div> 139 | </div> 140 | `; 141 | 142 | const toggleBtn = item.querySelector('.toggle-details-tab'); 143 | const detailsDiv = item.querySelector(`#google-auth-details-${UNIQUE_ID_BASE}`); 144 | const setupGisBtn = item.querySelector('.btn--setup-gis'); 145 | const clientIdInput = item.querySelector(`#clientIdInput-${UNIQUE_ID_BASE}`); 146 | const gisContainer = item.querySelector(`#gisContainer-${UNIQUE_ID_BASE}`); 147 | 148 | toggleBtn.addEventListener('click', () => { 149 | const isVisible = detailsDiv.style.display === 'flex'; 150 | detailsDiv.style.display = isVisible ? 'none' : 'flex'; 151 | toggleBtn.textContent = isVisible ? 'Auto Setup' : 'Close'; 152 | if (!isVisible) { 153 | if (gisContainer) { 154 | gisContainer.innerHTML = ''; 155 | gisContainer.style.display = 'none'; 156 | } 157 | if (setupGisBtn) { 158 | setupGisBtn.style.display = ''; 159 | } 160 | } 161 | }); 162 | 163 | setupGisBtn.addEventListener('click', () => { 164 | const clientId = clientIdInput.value; 165 | if (!clientId) { 166 | alert('Please enter an OAuth Client ID first.'); 167 | return; 168 | } 169 | renderGoogleSignInButton(toolId, clientId, authProfileName); 170 | }); 171 | 172 | return item; 173 | } 174 | ``` -------------------------------------------------------------------------------- /internal/sources/clickhouse/clickhouse_test.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package clickhouse 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/goccy/go-yaml" 23 | "github.com/google/go-cmp/cmp" 24 | "github.com/googleapis/genai-toolbox/internal/testutils" 25 | "go.opentelemetry.io/otel" 26 | ) 27 | 28 | func TestConfigSourceConfigKind(t *testing.T) { 29 | config := Config{} 30 | if config.SourceConfigKind() != SourceKind { 31 | t.Errorf("Expected %s, got %s", SourceKind, config.SourceConfigKind()) 32 | } 33 | } 34 | 35 | func TestNewConfig(t *testing.T) { 36 | tests := []struct { 37 | name string 38 | yaml string 39 | expected Config 40 | }{ 41 | { 42 | name: "all fields specified", 43 | yaml: ` 44 | name: test-clickhouse 45 | kind: clickhouse 46 | host: localhost 47 | port: "8443" 48 | user: default 49 | password: "mypass" 50 | database: mydb 51 | protocol: https 52 | secure: true 53 | `, 54 | expected: Config{ 55 | Name: "test-clickhouse", 56 | Kind: "clickhouse", 57 | Host: "localhost", 58 | Port: "8443", 59 | User: "default", 60 | Password: "mypass", 61 | Database: "mydb", 62 | Protocol: "https", 63 | Secure: true, 64 | }, 65 | }, 66 | { 67 | name: "minimal configuration with defaults", 68 | yaml: ` 69 | name: minimal-clickhouse 70 | kind: clickhouse 71 | host: 127.0.0.1 72 | port: "8123" 73 | user: testuser 74 | database: testdb 75 | `, 76 | expected: Config{ 77 | Name: "minimal-clickhouse", 78 | Kind: "clickhouse", 79 | Host: "127.0.0.1", 80 | Port: "8123", 81 | User: "testuser", 82 | Password: "", 83 | Database: "testdb", 84 | Protocol: "", 85 | Secure: false, 86 | }, 87 | }, 88 | { 89 | name: "http protocol", 90 | yaml: ` 91 | name: http-clickhouse 92 | kind: clickhouse 93 | host: clickhouse.example.com 94 | port: "8123" 95 | user: analytics 96 | password: "securepass" 97 | database: analytics_db 98 | protocol: http 99 | secure: false 100 | `, 101 | expected: Config{ 102 | Name: "http-clickhouse", 103 | Kind: "clickhouse", 104 | Host: "clickhouse.example.com", 105 | Port: "8123", 106 | User: "analytics", 107 | Password: "securepass", 108 | Database: "analytics_db", 109 | Protocol: "http", 110 | Secure: false, 111 | }, 112 | }, 113 | { 114 | name: "https with secure connection", 115 | yaml: ` 116 | name: secure-clickhouse 117 | kind: clickhouse 118 | host: secure.clickhouse.io 119 | port: "8443" 120 | user: secureuser 121 | password: "verysecure" 122 | database: production 123 | protocol: https 124 | secure: true 125 | `, 126 | expected: Config{ 127 | Name: "secure-clickhouse", 128 | Kind: "clickhouse", 129 | Host: "secure.clickhouse.io", 130 | Port: "8443", 131 | User: "secureuser", 132 | Password: "verysecure", 133 | Database: "production", 134 | Protocol: "https", 135 | Secure: true, 136 | }, 137 | }, 138 | } 139 | 140 | for _, tt := range tests { 141 | t.Run(tt.name, func(t *testing.T) { 142 | decoder := yaml.NewDecoder(strings.NewReader(string(testutils.FormatYaml(tt.yaml)))) 143 | config, err := newConfig(context.Background(), tt.expected.Name, decoder) 144 | if err != nil { 145 | t.Fatalf("Failed to create config: %v", err) 146 | } 147 | 148 | clickhouseConfig, ok := config.(Config) 149 | if !ok { 150 | t.Fatalf("Expected Config type, got %T", config) 151 | } 152 | 153 | if diff := cmp.Diff(tt.expected, clickhouseConfig); diff != "" { 154 | t.Errorf("Config mismatch (-want +got):\n%s", diff) 155 | } 156 | }) 157 | } 158 | } 159 | 160 | func TestNewConfigInvalidYAML(t *testing.T) { 161 | tests := []struct { 162 | name string 163 | yaml string 164 | expectError bool 165 | }{ 166 | { 167 | name: "invalid yaml syntax", 168 | yaml: ` 169 | name: test-clickhouse 170 | kind: clickhouse 171 | host: [invalid 172 | `, 173 | expectError: true, 174 | }, 175 | { 176 | name: "missing required fields", 177 | yaml: ` 178 | name: test-clickhouse 179 | kind: clickhouse 180 | `, 181 | expectError: false, 182 | }, 183 | } 184 | 185 | for _, tt := range tests { 186 | t.Run(tt.name, func(t *testing.T) { 187 | decoder := yaml.NewDecoder(strings.NewReader(string(testutils.FormatYaml(tt.yaml)))) 188 | _, err := newConfig(context.Background(), "test-clickhouse", decoder) 189 | if tt.expectError && err == nil { 190 | t.Errorf("Expected error but got none") 191 | } 192 | if !tt.expectError && err != nil { 193 | t.Errorf("Expected no error but got: %v", err) 194 | } 195 | }) 196 | } 197 | } 198 | 199 | func TestSource_SourceKind(t *testing.T) { 200 | source := &Source{} 201 | if source.SourceKind() != SourceKind { 202 | t.Errorf("Expected %s, got %s", SourceKind, source.SourceKind()) 203 | } 204 | } 205 | 206 | func TestValidateConfig(t *testing.T) { 207 | tests := []struct { 208 | name string 209 | protocol string 210 | expectError bool 211 | }{ 212 | { 213 | name: "valid https protocol", 214 | protocol: "https", 215 | expectError: false, 216 | }, 217 | { 218 | name: "valid http protocol", 219 | protocol: "http", 220 | expectError: false, 221 | }, 222 | { 223 | name: "invalid protocol", 224 | protocol: "invalid", 225 | expectError: true, 226 | }, 227 | { 228 | name: "invalid protocol - native not supported", 229 | protocol: "native", 230 | expectError: true, 231 | }, 232 | { 233 | name: "empty values use defaults", 234 | protocol: "", 235 | expectError: false, 236 | }, 237 | } 238 | 239 | for _, tt := range tests { 240 | t.Run(tt.name, func(t *testing.T) { 241 | err := validateConfig(tt.protocol) 242 | if tt.expectError && err == nil { 243 | t.Errorf("Expected error but got none") 244 | } 245 | if !tt.expectError && err != nil { 246 | t.Errorf("Expected no error but got: %v", err) 247 | } 248 | }) 249 | } 250 | } 251 | 252 | func TestInitClickHouseConnectionPoolDSNGeneration(t *testing.T) { 253 | tracer := otel.Tracer("test") 254 | ctx := context.Background() 255 | 256 | tests := []struct { 257 | name string 258 | host string 259 | port string 260 | user string 261 | pass string 262 | dbname string 263 | protocol string 264 | secure bool 265 | shouldErr bool 266 | }{ 267 | { 268 | name: "http protocol with defaults", 269 | host: "localhost", 270 | port: "8123", 271 | user: "default", 272 | pass: "", 273 | dbname: "default", 274 | protocol: "http", 275 | secure: false, 276 | shouldErr: true, 277 | }, 278 | { 279 | name: "https protocol with secure", 280 | host: "localhost", 281 | port: "8443", 282 | user: "default", 283 | pass: "", 284 | dbname: "default", 285 | protocol: "https", 286 | secure: true, 287 | shouldErr: true, 288 | }, 289 | { 290 | name: "special characters in password", 291 | host: "localhost", 292 | port: "8443", 293 | user: "test@user", 294 | pass: "pass@word:with/special&chars", 295 | dbname: "default", 296 | protocol: "https", 297 | secure: true, 298 | shouldErr: true, 299 | }, 300 | { 301 | name: "invalid protocol should fail", 302 | host: "localhost", 303 | port: "9000", 304 | user: "default", 305 | pass: "", 306 | dbname: "default", 307 | protocol: "invalid", 308 | secure: false, 309 | shouldErr: true, 310 | }, 311 | { 312 | name: "empty protocol defaults to https", 313 | host: "localhost", 314 | port: "8443", 315 | user: "user", 316 | pass: "pass", 317 | dbname: "testdb", 318 | protocol: "", 319 | secure: true, 320 | shouldErr: true, 321 | }, 322 | { 323 | name: "http with secure flag should upgrade to https", 324 | host: "example.com", 325 | port: "8443", 326 | user: "user", 327 | pass: "pass", 328 | dbname: "db", 329 | protocol: "http", 330 | secure: true, 331 | shouldErr: true, 332 | }, 333 | } 334 | 335 | for _, tt := range tests { 336 | t.Run(tt.name, func(t *testing.T) { 337 | pool, err := initClickHouseConnectionPool(ctx, tracer, "test", tt.host, tt.port, tt.user, tt.pass, tt.dbname, tt.protocol, tt.secure) 338 | 339 | if !tt.shouldErr && err != nil { 340 | t.Errorf("Expected no error, got: %v", err) 341 | } 342 | 343 | if pool != nil { 344 | pool.Close() 345 | } 346 | }) 347 | } 348 | } 349 | ``` -------------------------------------------------------------------------------- /docs/en/getting-started/mcp_quickstart/_index.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: "Quickstart (MCP)" 3 | type: docs 4 | weight: 5 5 | description: > 6 | How to get started running Toolbox locally with MCP Inspector. 7 | --- 8 | 9 | ## Overview 10 | 11 | [Model Context Protocol](https://modelcontextprotocol.io) is an open protocol 12 | that standardizes how applications provide context to LLMs. Check out this page 13 | on how to [connect to Toolbox via MCP](../../how-to/connect_via_mcp.md). 14 | 15 | ## Step 1: Set up your database 16 | 17 | In this section, we will create a database, insert some data that needs to be 18 | access by our agent, and create a database user for Toolbox to connect with. 19 | 20 | 1. Connect to postgres using the `psql` command: 21 | 22 | ```bash 23 | psql -h 127.0.0.1 -U postgres 24 | ``` 25 | 26 | Here, `postgres` denotes the default postgres superuser. 27 | 28 | 1. Create a new database and a new user: 29 | 30 | {{< notice tip >}} 31 | For a real application, it's best to follow the principle of least permission 32 | and only grant the privileges your application needs. 33 | {{< /notice >}} 34 | 35 | ```sql 36 | CREATE USER toolbox_user WITH PASSWORD 'my-password'; 37 | 38 | CREATE DATABASE toolbox_db; 39 | GRANT ALL PRIVILEGES ON DATABASE toolbox_db TO toolbox_user; 40 | 41 | ALTER DATABASE toolbox_db OWNER TO toolbox_user; 42 | ``` 43 | 44 | 1. End the database session: 45 | 46 | ```bash 47 | \q 48 | ``` 49 | 50 | 1. Connect to your database with your new user: 51 | 52 | ```bash 53 | psql -h 127.0.0.1 -U toolbox_user -d toolbox_db 54 | ``` 55 | 56 | 1. Create a table using the following command: 57 | 58 | ```sql 59 | CREATE TABLE hotels( 60 | id INTEGER NOT NULL PRIMARY KEY, 61 | name VARCHAR NOT NULL, 62 | location VARCHAR NOT NULL, 63 | price_tier VARCHAR NOT NULL, 64 | checkin_date DATE NOT NULL, 65 | checkout_date DATE NOT NULL, 66 | booked BIT NOT NULL 67 | ); 68 | ``` 69 | 70 | 1. Insert data into the table. 71 | 72 | ```sql 73 | INSERT INTO hotels(id, name, location, price_tier, checkin_date, checkout_date, booked) 74 | VALUES 75 | (1, 'Hilton Basel', 'Basel', 'Luxury', '2024-04-22', '2024-04-20', B'0'), 76 | (2, 'Marriott Zurich', 'Zurich', 'Upscale', '2024-04-14', '2024-04-21', B'0'), 77 | (3, 'Hyatt Regency Basel', 'Basel', 'Upper Upscale', '2024-04-02', '2024-04-20', B'0'), 78 | (4, 'Radisson Blu Lucerne', 'Lucerne', 'Midscale', '2024-04-24', '2024-04-05', B'0'), 79 | (5, 'Best Western Bern', 'Bern', 'Upper Midscale', '2024-04-23', '2024-04-01', B'0'), 80 | (6, 'InterContinental Geneva', 'Geneva', 'Luxury', '2024-04-23', '2024-04-28', B'0'), 81 | (7, 'Sheraton Zurich', 'Zurich', 'Upper Upscale', '2024-04-27', '2024-04-02', B'0'), 82 | (8, 'Holiday Inn Basel', 'Basel', 'Upper Midscale', '2024-04-24', '2024-04-09', B'0'), 83 | (9, 'Courtyard Zurich', 'Zurich', 'Upscale', '2024-04-03', '2024-04-13', B'0'), 84 | (10, 'Comfort Inn Bern', 'Bern', 'Midscale', '2024-04-04', '2024-04-16', B'0'); 85 | ``` 86 | 87 | 1. End the database session: 88 | 89 | ```bash 90 | \q 91 | ``` 92 | 93 | ## Step 2: Install and configure Toolbox 94 | 95 | In this section, we will download Toolbox, configure our tools in a 96 | `tools.yaml`, and then run the Toolbox server. 97 | 98 | 1. Download the latest version of Toolbox as a binary: 99 | 100 | {{< notice tip >}} 101 | Select the 102 | [correct binary](https://github.com/googleapis/genai-toolbox/releases) 103 | corresponding to your OS and CPU architecture. 104 | {{< /notice >}} 105 | <!-- {x-release-please-start-version} --> 106 | ```bash 107 | export OS="linux/amd64" # one of linux/amd64, darwin/arm64, darwin/amd64, or windows/amd64 108 | curl -O https://storage.googleapis.com/genai-toolbox/v0.17.0/$OS/toolbox 109 | ``` 110 | <!-- {x-release-please-end} --> 111 | 112 | 1. Make the binary executable: 113 | 114 | ```bash 115 | chmod +x toolbox 116 | ``` 117 | 118 | 1. Write the following into a `tools.yaml` file. Be sure to update any fields 119 | such as `user`, `password`, or `database` that you may have customized in the 120 | previous step. 121 | 122 | {{< notice tip >}} 123 | In practice, use environment variable replacement with the format ${ENV_NAME} 124 | instead of hardcoding your secrets into the configuration file. 125 | {{< /notice >}} 126 | 127 | ```yaml 128 | sources: 129 | my-pg-source: 130 | kind: postgres 131 | host: 127.0.0.1 132 | port: 5432 133 | database: toolbox_db 134 | user: toolbox_user 135 | password: my-password 136 | tools: 137 | search-hotels-by-name: 138 | kind: postgres-sql 139 | source: my-pg-source 140 | description: Search for hotels based on name. 141 | parameters: 142 | - name: name 143 | type: string 144 | description: The name of the hotel. 145 | statement: SELECT * FROM hotels WHERE name ILIKE '%' || $1 || '%'; 146 | search-hotels-by-location: 147 | kind: postgres-sql 148 | source: my-pg-source 149 | description: Search for hotels based on location. 150 | parameters: 151 | - name: location 152 | type: string 153 | description: The location of the hotel. 154 | statement: SELECT * FROM hotels WHERE location ILIKE '%' || $1 || '%'; 155 | book-hotel: 156 | kind: postgres-sql 157 | source: my-pg-source 158 | description: >- 159 | Book a hotel by its ID. If the hotel is successfully booked, returns a NULL, raises an error if not. 160 | parameters: 161 | - name: hotel_id 162 | type: string 163 | description: The ID of the hotel to book. 164 | statement: UPDATE hotels SET booked = B'1' WHERE id = $1; 165 | update-hotel: 166 | kind: postgres-sql 167 | source: my-pg-source 168 | description: >- 169 | Update a hotel's check-in and check-out dates by its ID. Returns a message 170 | indicating whether the hotel was successfully updated or not. 171 | parameters: 172 | - name: hotel_id 173 | type: string 174 | description: The ID of the hotel to update. 175 | - name: checkin_date 176 | type: string 177 | description: The new check-in date of the hotel. 178 | - name: checkout_date 179 | type: string 180 | description: The new check-out date of the hotel. 181 | statement: >- 182 | UPDATE hotels SET checkin_date = CAST($2 as date), checkout_date = CAST($3 183 | as date) WHERE id = $1; 184 | cancel-hotel: 185 | kind: postgres-sql 186 | source: my-pg-source 187 | description: Cancel a hotel by its ID. 188 | parameters: 189 | - name: hotel_id 190 | type: string 191 | description: The ID of the hotel to cancel. 192 | statement: UPDATE hotels SET booked = B'0' WHERE id = $1; 193 | toolsets: 194 | my-toolset: 195 | - search-hotels-by-name 196 | - search-hotels-by-location 197 | - book-hotel 198 | - update-hotel 199 | - cancel-hotel 200 | ``` 201 | 202 | For more info on tools, check out the 203 | [Tools](../../resources/tools/) section. 204 | 205 | 1. Run the Toolbox server, pointing to the `tools.yaml` file created earlier: 206 | 207 | ```bash 208 | ./toolbox --tools-file "tools.yaml" 209 | ``` 210 | 211 | ## Step 3: Connect to MCP Inspector 212 | 213 | 1. Run the MCP Inspector: 214 | 215 | ```bash 216 | npx @modelcontextprotocol/inspector 217 | ``` 218 | 219 | 1. Type `y` when it asks to install the inspector package. 220 | 221 | 1. It should show the following when the MCP Inspector is up and running (please 222 | take note of `<YOUR_SESSION_TOKEN>`): 223 | 224 | ```bash 225 | Starting MCP inspector... 226 | ⚙️ Proxy server listening on localhost:6277 227 | 🔑 Session token: <YOUR_SESSION_TOKEN> 228 | Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth 229 | 230 | 🚀 MCP Inspector is up and running at: 231 | http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=<YOUR_SESSION_TOKEN> 232 | ``` 233 | 234 | 1. Open the above link in your browser. 235 | 236 | 1. For `Transport Type`, select `Streamable HTTP`. 237 | 238 | 1. For `URL`, type in `http://127.0.0.1:5000/mcp`. 239 | 240 | 1. For `Configuration` -> `Proxy Session Token`, make sure 241 | `<YOUR_SESSION_TOKEN>` is present. 242 | 243 | 1. Click Connect. 244 | 245 |  246 | 247 | 1. Select `List Tools`, you will see a list of tools configured in `tools.yaml`. 248 | 249 |  250 | 251 | 1. Test out your tools here! 252 | ``` -------------------------------------------------------------------------------- /.github/workflows/cloud_build_failure_reporter.yml: -------------------------------------------------------------------------------- ```yaml 1 | # Copyright 2025 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Cloud Build Failure Reporter 16 | 17 | on: 18 | workflow_call: 19 | inputs: 20 | trigger_names: 21 | required: true 22 | type: string 23 | workflow_dispatch: 24 | inputs: 25 | trigger_names: 26 | description: 'Cloud Build trigger names separated by comma.' 27 | required: true 28 | default: '' 29 | 30 | jobs: 31 | report: 32 | 33 | permissions: 34 | issues: 'write' 35 | checks: 'read' 36 | 37 | runs-on: 'ubuntu-latest' 38 | 39 | steps: 40 | - uses: 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' # v8 41 | with: 42 | script: |- 43 | // parse test names 44 | const testNameSubstring = '${{ inputs.trigger_names }}'; 45 | const testNameFound = new Map(); //keeps track of whether each test is found 46 | testNameSubstring.split(',').forEach(testName => { 47 | testNameFound.set(testName, false); 48 | }); 49 | 50 | // label for all issues opened by reporter 51 | const periodicLabel = 'periodic-failure'; 52 | 53 | // check if any reporter opened any issues previously 54 | const prevIssues = await github.paginate(github.rest.issues.listForRepo, { 55 | ...context.repo, 56 | state: 'open', 57 | creator: 'github-actions[bot]', 58 | labels: [periodicLabel] 59 | }); 60 | 61 | // createOrCommentIssue creates a new issue or comments on an existing issue. 62 | const createOrCommentIssue = async function (title, txt) { 63 | if (prevIssues.length < 1) { 64 | console.log('no previous issues found, creating one'); 65 | await github.rest.issues.create({ 66 | ...context.repo, 67 | title: title, 68 | body: txt, 69 | labels: [periodicLabel] 70 | }); 71 | return; 72 | } 73 | // only comment on issue related to the current test 74 | for (const prevIssue of prevIssues) { 75 | if (prevIssue.title.includes(title)){ 76 | console.log( 77 | `found previous issue ${prevIssue.html_url}, adding comment` 78 | ); 79 | 80 | await github.rest.issues.createComment({ 81 | ...context.repo, 82 | issue_number: prevIssue.number, 83 | body: txt 84 | }); 85 | return; 86 | } 87 | } 88 | }; 89 | 90 | // updateIssues comments on any existing issues. No-op if no issue exists. 91 | const updateIssues = async function (checkName, txt) { 92 | if (prevIssues.length < 1) { 93 | console.log('no previous issues found.'); 94 | return; 95 | } 96 | // only comment on issue related to the current test 97 | for (const prevIssue of prevIssues) { 98 | if (prevIssue.title.includes(checkName)){ 99 | console.log(`found previous issue ${prevIssue.html_url}, adding comment`); 100 | await github.rest.issues.createComment({ 101 | ...context.repo, 102 | issue_number: prevIssue.number, 103 | body: txt 104 | }); 105 | } 106 | } 107 | }; 108 | 109 | // Find status of check runs. 110 | // We will find check runs for each commit and then filter for the periodic. 111 | // Checks API only allows for ref and if we use main there could be edge cases where 112 | // the check run happened on a SHA that is different from head. 113 | const commits = await github.paginate(github.rest.repos.listCommits, { 114 | ...context.repo 115 | }); 116 | 117 | const relevantChecks = new Map(); 118 | for (const commit of commits) { 119 | console.log( 120 | `checking runs at ${commit.html_url}: ${commit.commit.message}` 121 | ); 122 | const checks = await github.rest.checks.listForRef({ 123 | ...context.repo, 124 | ref: commit.sha 125 | }); 126 | 127 | // Iterate through each check and find matching names 128 | for (const check of checks.data.check_runs) { 129 | console.log(`Handling test name ${check.name}`); 130 | for (const testName of testNameFound.keys()) { 131 | if (testNameFound.get(testName) === true){ 132 | //skip if a check is already found for this name 133 | continue; 134 | } 135 | if (check.name.includes(testName)) { 136 | relevantChecks.set(check, commit); 137 | testNameFound.set(testName, true); 138 | } 139 | } 140 | } 141 | // Break out of the loop early if all tests are found 142 | const allTestsFound = Array.from(testNameFound.values()).every(value => value === true); 143 | if (allTestsFound){ 144 | break; 145 | } 146 | } 147 | 148 | // Handle each relevant check 149 | relevantChecks.forEach((commit, check) => { 150 | if ( 151 | check.status === 'completed' && 152 | check.conclusion === 'success' 153 | ) { 154 | updateIssues( 155 | check.name, 156 | `[Tests are passing](${check.html_url}) for commit [${commit.sha}](${commit.html_url}).` 157 | ); 158 | } else if (check.status === 'in_progress') { 159 | console.log( 160 | `Check is pending ${check.html_url} for ${commit.html_url}. Retry again later.` 161 | ); 162 | } else { 163 | createOrCommentIssue( 164 | `Cloud Build Failure Reporter: ${check.name} failed`, 165 | `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.` 166 | ); 167 | } 168 | }); 169 | 170 | // no periodic checks found across all commits, report it 171 | const noTestFound = Array.from(testNameFound.values()).every(value => value === false); 172 | if (noTestFound){ 173 | createOrCommentIssue( 174 | 'Missing periodic tests: ${{ inputs.trigger_names }}', 175 | `No periodic test is found for triggers: ${{ inputs.trigger_names }}. Last checked from ${ 176 | commits[0].html_url 177 | } to ${commits[commits.length - 1].html_url}.` 178 | ); 179 | } 180 | ``` -------------------------------------------------------------------------------- /internal/server/api_test.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package server 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | "io" 22 | "net/http" 23 | "strings" 24 | "testing" 25 | 26 | "github.com/googleapis/genai-toolbox/internal/tools" 27 | ) 28 | 29 | func TestToolsetEndpoint(t *testing.T) { 30 | mockTools := []MockTool{tool1, tool2} 31 | toolsMap, toolsets := setUpResources(t, mockTools) 32 | r, shutdown := setUpServer(t, "api", toolsMap, toolsets) 33 | defer shutdown() 34 | ts := runServer(r, false) 35 | defer ts.Close() 36 | 37 | // wantResponse is a struct for checks against test cases 38 | type wantResponse struct { 39 | statusCode int 40 | isErr bool 41 | version string 42 | tools []string 43 | } 44 | 45 | testCases := []struct { 46 | name string 47 | toolsetName string 48 | want wantResponse 49 | }{ 50 | { 51 | name: "'default' manifest", 52 | toolsetName: "", 53 | want: wantResponse{ 54 | statusCode: http.StatusOK, 55 | version: fakeVersionString, 56 | tools: []string{tool1.Name, tool2.Name}, 57 | }, 58 | }, 59 | { 60 | name: "invalid toolset name", 61 | toolsetName: "some_imaginary_toolset", 62 | want: wantResponse{ 63 | statusCode: http.StatusNotFound, 64 | isErr: true, 65 | }, 66 | }, 67 | { 68 | name: "single toolset 1", 69 | toolsetName: "tool1_only", 70 | want: wantResponse{ 71 | statusCode: http.StatusOK, 72 | version: fakeVersionString, 73 | tools: []string{tool1.Name}, 74 | }, 75 | }, 76 | { 77 | name: "single toolset 2", 78 | toolsetName: "tool2_only", 79 | want: wantResponse{ 80 | statusCode: http.StatusOK, 81 | version: fakeVersionString, 82 | tools: []string{tool2.Name}, 83 | }, 84 | }, 85 | } 86 | 87 | for _, tc := range testCases { 88 | t.Run(tc.name, func(t *testing.T) { 89 | resp, body, err := runRequest(ts, http.MethodGet, fmt.Sprintf("/toolset/%s", tc.toolsetName), nil, nil) 90 | if err != nil { 91 | t.Fatalf("unexpected error during request: %s", err) 92 | } 93 | 94 | if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { 95 | t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) 96 | } 97 | 98 | if resp.StatusCode != tc.want.statusCode { 99 | t.Logf("response body: %s", body) 100 | t.Fatalf("unexpected status code: want %d, got %d", tc.want.statusCode, resp.StatusCode) 101 | } 102 | if tc.want.isErr { 103 | // skip the rest of the checks if this is an error case 104 | return 105 | } 106 | var m tools.ToolsetManifest 107 | err = json.Unmarshal(body, &m) 108 | if err != nil { 109 | t.Fatalf("unable to parse ToolsetManifest: %s", err) 110 | } 111 | // Check the version is correct 112 | if m.ServerVersion != tc.want.version { 113 | t.Fatalf("unexpected ServerVersion: want %q, got %q", tc.want.version, m.ServerVersion) 114 | } 115 | // validate that the tools in the toolset are correct 116 | for _, name := range tc.want.tools { 117 | _, ok := m.ToolsManifest[name] 118 | if !ok { 119 | t.Errorf("%q tool not found in manifest", name) 120 | } 121 | } 122 | }) 123 | } 124 | } 125 | 126 | func TestToolGetEndpoint(t *testing.T) { 127 | mockTools := []MockTool{tool1, tool2} 128 | toolsMap, toolsets := setUpResources(t, mockTools) 129 | r, shutdown := setUpServer(t, "api", toolsMap, toolsets) 130 | defer shutdown() 131 | ts := runServer(r, false) 132 | defer ts.Close() 133 | 134 | // wantResponse is a struct for checks against test cases 135 | type wantResponse struct { 136 | statusCode int 137 | isErr bool 138 | version string 139 | tools []string 140 | } 141 | 142 | testCases := []struct { 143 | name string 144 | toolName string 145 | want wantResponse 146 | }{ 147 | { 148 | name: "tool1", 149 | toolName: tool1.Name, 150 | want: wantResponse{ 151 | statusCode: http.StatusOK, 152 | version: fakeVersionString, 153 | tools: []string{tool1.Name}, 154 | }, 155 | }, 156 | { 157 | name: "tool2", 158 | toolName: tool2.Name, 159 | want: wantResponse{ 160 | statusCode: http.StatusOK, 161 | version: fakeVersionString, 162 | tools: []string{tool2.Name}, 163 | }, 164 | }, 165 | { 166 | name: "invalid tool", 167 | toolName: "some_imaginary_tool", 168 | want: wantResponse{ 169 | statusCode: http.StatusNotFound, 170 | isErr: true, 171 | }, 172 | }, 173 | } 174 | 175 | for _, tc := range testCases { 176 | t.Run(tc.name, func(t *testing.T) { 177 | resp, body, err := runRequest(ts, http.MethodGet, fmt.Sprintf("/tool/%s", tc.toolName), nil, nil) 178 | if err != nil { 179 | t.Fatalf("unexpected error during request: %s", err) 180 | } 181 | 182 | if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { 183 | t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) 184 | } 185 | 186 | if resp.StatusCode != tc.want.statusCode { 187 | t.Logf("response body: %s", body) 188 | t.Fatalf("unexpected status code: want %d, got %d", tc.want.statusCode, resp.StatusCode) 189 | } 190 | if tc.want.isErr { 191 | // skip the rest of the checks if this is an error case 192 | return 193 | } 194 | var m tools.ToolsetManifest 195 | err = json.Unmarshal(body, &m) 196 | if err != nil { 197 | t.Fatalf("unable to parse ToolsetManifest: %s", err) 198 | } 199 | // Check the version is correct 200 | if m.ServerVersion != tc.want.version { 201 | t.Fatalf("unexpected ServerVersion: want %q, got %q", tc.want.version, m.ServerVersion) 202 | } 203 | // validate that the tools in the toolset are correct 204 | for _, name := range tc.want.tools { 205 | _, ok := m.ToolsManifest[name] 206 | if !ok { 207 | t.Errorf("%q tool not found in manifest", name) 208 | } 209 | } 210 | }) 211 | } 212 | } 213 | 214 | func TestToolInvokeEndpoint(t *testing.T) { 215 | mockTools := []MockTool{tool1, tool2, tool4, tool5} 216 | toolsMap, toolsets := setUpResources(t, mockTools) 217 | r, shutdown := setUpServer(t, "api", toolsMap, toolsets) 218 | defer shutdown() 219 | ts := runServer(r, false) 220 | defer ts.Close() 221 | 222 | testCases := []struct { 223 | name string 224 | toolName string 225 | requestBody io.Reader 226 | want string 227 | isErr bool 228 | }{ 229 | { 230 | name: "tool1", 231 | toolName: tool1.Name, 232 | requestBody: bytes.NewBuffer([]byte(`{}`)), 233 | want: "{result:[no_params]}\n", 234 | isErr: false, 235 | }, 236 | { 237 | name: "tool2", 238 | toolName: tool2.Name, 239 | requestBody: bytes.NewBuffer([]byte(`{"param1": 1, "param2": 2}`)), 240 | want: "{result:[some_params]}\n", 241 | isErr: false, 242 | }, 243 | { 244 | name: "invalid tool", 245 | toolName: "some_imaginary_tool", 246 | requestBody: bytes.NewBuffer([]byte(`{}`)), 247 | want: "", 248 | isErr: true, 249 | }, 250 | { 251 | name: "tool4", 252 | toolName: tool4.Name, 253 | requestBody: bytes.NewBuffer([]byte(`{}`)), 254 | want: "", 255 | isErr: true, 256 | }, 257 | { 258 | name: "tool5", 259 | toolName: tool5.Name, 260 | requestBody: bytes.NewBuffer([]byte(`{}`)), 261 | want: "", 262 | isErr: true, 263 | }, 264 | } 265 | 266 | for _, tc := range testCases { 267 | t.Run(tc.name, func(t *testing.T) { 268 | resp, body, err := runRequest(ts, http.MethodPost, fmt.Sprintf("/tool/%s/invoke", tc.toolName), tc.requestBody, nil) 269 | if err != nil { 270 | t.Fatalf("unexpected error during request: %s", err) 271 | } 272 | 273 | if contentType := resp.Header.Get("Content-type"); contentType != "application/json" { 274 | t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType) 275 | } 276 | 277 | if resp.StatusCode != http.StatusOK { 278 | if tc.isErr == true { 279 | return 280 | } 281 | t.Fatalf("response status code is not 200, got %d, %s", resp.StatusCode, string(body)) 282 | } 283 | 284 | got := string(body) 285 | 286 | // Remove `\` and `"` for string comparison 287 | got = strings.ReplaceAll(got, "\\", "") 288 | want := strings.ReplaceAll(tc.want, "\\", "") 289 | got = strings.ReplaceAll(got, "\"", "") 290 | want = strings.ReplaceAll(want, "\"", "") 291 | 292 | if got != want { 293 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want) 294 | } 295 | }) 296 | } 297 | } 298 | ``` -------------------------------------------------------------------------------- /internal/server/config.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package server 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "strings" 20 | 21 | yaml "github.com/goccy/go-yaml" 22 | "github.com/googleapis/genai-toolbox/internal/auth" 23 | "github.com/googleapis/genai-toolbox/internal/auth/google" 24 | "github.com/googleapis/genai-toolbox/internal/sources" 25 | "github.com/googleapis/genai-toolbox/internal/tools" 26 | "github.com/googleapis/genai-toolbox/internal/util" 27 | ) 28 | 29 | type ServerConfig struct { 30 | // Server version 31 | Version string 32 | // Address is the address of the interface the server will listen on. 33 | Address string 34 | // Port is the port the server will listen on. 35 | Port int 36 | // SourceConfigs defines what sources of data are available for tools. 37 | SourceConfigs SourceConfigs 38 | // AuthServiceConfigs defines what sources of authentication are available for tools. 39 | AuthServiceConfigs AuthServiceConfigs 40 | // ToolConfigs defines what tools are available. 41 | ToolConfigs ToolConfigs 42 | // ToolsetConfigs defines what tools are available. 43 | ToolsetConfigs ToolsetConfigs 44 | // LoggingFormat defines whether structured loggings are used. 45 | LoggingFormat logFormat 46 | // LogLevel defines the levels to log. 47 | LogLevel StringLevel 48 | // TelemetryGCP defines whether GCP exporter is used. 49 | TelemetryGCP bool 50 | // TelemetryOTLP defines OTLP collector url for telemetry exports. 51 | TelemetryOTLP string 52 | // TelemetryServiceName defines the value of service.name resource attribute. 53 | TelemetryServiceName string 54 | // Stdio indicates if Toolbox is listening via MCP stdio. 55 | Stdio bool 56 | // DisableReload indicates if the user has disabled dynamic reloading for Toolbox. 57 | DisableReload bool 58 | // UI indicates if Toolbox UI endpoints (/ui) are available 59 | UI bool 60 | } 61 | 62 | type logFormat string 63 | 64 | // String is used by both fmt.Print and by Cobra in help text 65 | func (f *logFormat) String() string { 66 | if string(*f) != "" { 67 | return strings.ToLower(string(*f)) 68 | } 69 | return "standard" 70 | } 71 | 72 | // validate logging format flag 73 | func (f *logFormat) Set(v string) error { 74 | switch strings.ToLower(v) { 75 | case "standard", "json": 76 | *f = logFormat(v) 77 | return nil 78 | default: 79 | return fmt.Errorf(`log format must be one of "standard", or "json"`) 80 | } 81 | } 82 | 83 | // Type is used in Cobra help text 84 | func (f *logFormat) Type() string { 85 | return "logFormat" 86 | } 87 | 88 | type StringLevel string 89 | 90 | // String is used by both fmt.Print and by Cobra in help text 91 | func (s *StringLevel) String() string { 92 | if string(*s) != "" { 93 | return strings.ToLower(string(*s)) 94 | } 95 | return "info" 96 | } 97 | 98 | // validate log level flag 99 | func (s *StringLevel) Set(v string) error { 100 | switch strings.ToLower(v) { 101 | case "debug", "info", "warn", "error": 102 | *s = StringLevel(v) 103 | return nil 104 | default: 105 | return fmt.Errorf(`log level must be one of "debug", "info", "warn", or "error"`) 106 | } 107 | } 108 | 109 | // Type is used in Cobra help text 110 | func (s *StringLevel) Type() string { 111 | return "stringLevel" 112 | } 113 | 114 | // SourceConfigs is a type used to allow unmarshal of the data source config map 115 | type SourceConfigs map[string]sources.SourceConfig 116 | 117 | // validate interface 118 | var _ yaml.InterfaceUnmarshalerContext = &SourceConfigs{} 119 | 120 | func (c *SourceConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { 121 | *c = make(SourceConfigs) 122 | // Parse the 'kind' fields for each source 123 | var raw map[string]util.DelayedUnmarshaler 124 | if err := unmarshal(&raw); err != nil { 125 | return err 126 | } 127 | 128 | for name, u := range raw { 129 | // Unmarshal to a general type that ensure it capture all fields 130 | var v map[string]any 131 | if err := u.Unmarshal(&v); err != nil { 132 | return fmt.Errorf("unable to unmarshal %q: %w", name, err) 133 | } 134 | 135 | kind, ok := v["kind"] 136 | if !ok { 137 | return fmt.Errorf("missing 'kind' field for source %q", name) 138 | } 139 | kindStr, ok := kind.(string) 140 | if !ok { 141 | return fmt.Errorf("invalid 'kind' field for source %q (must be a string)", name) 142 | } 143 | 144 | yamlDecoder, err := util.NewStrictDecoder(v) 145 | if err != nil { 146 | return fmt.Errorf("error creating YAML decoder for source %q: %w", name, err) 147 | } 148 | 149 | sourceConfig, err := sources.DecodeConfig(ctx, kindStr, name, yamlDecoder) 150 | if err != nil { 151 | return err 152 | } 153 | (*c)[name] = sourceConfig 154 | } 155 | return nil 156 | } 157 | 158 | // AuthServiceConfigs is a type used to allow unmarshal of the data authService config map 159 | type AuthServiceConfigs map[string]auth.AuthServiceConfig 160 | 161 | // validate interface 162 | var _ yaml.InterfaceUnmarshalerContext = &AuthServiceConfigs{} 163 | 164 | func (c *AuthServiceConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { 165 | *c = make(AuthServiceConfigs) 166 | // Parse the 'kind' fields for each authService 167 | var raw map[string]util.DelayedUnmarshaler 168 | if err := unmarshal(&raw); err != nil { 169 | return err 170 | } 171 | 172 | for name, u := range raw { 173 | var v map[string]any 174 | if err := u.Unmarshal(&v); err != nil { 175 | return fmt.Errorf("unable to unmarshal %q: %w", name, err) 176 | } 177 | 178 | kind, ok := v["kind"] 179 | if !ok { 180 | return fmt.Errorf("missing 'kind' field for %q", name) 181 | } 182 | 183 | dec, err := util.NewStrictDecoder(v) 184 | if err != nil { 185 | return fmt.Errorf("error creating decoder: %w", err) 186 | } 187 | switch kind { 188 | case google.AuthServiceKind: 189 | actual := google.Config{Name: name} 190 | if err := dec.DecodeContext(ctx, &actual); err != nil { 191 | return fmt.Errorf("unable to parse as %q: %w", kind, err) 192 | } 193 | (*c)[name] = actual 194 | default: 195 | return fmt.Errorf("%q is not a valid kind of auth source", kind) 196 | } 197 | } 198 | return nil 199 | } 200 | 201 | // ToolConfigs is a type used to allow unmarshal of the tool configs 202 | type ToolConfigs map[string]tools.ToolConfig 203 | 204 | // validate interface 205 | var _ yaml.InterfaceUnmarshalerContext = &ToolConfigs{} 206 | 207 | func (c *ToolConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { 208 | *c = make(ToolConfigs) 209 | // Parse the 'kind' fields for each source 210 | var raw map[string]util.DelayedUnmarshaler 211 | if err := unmarshal(&raw); err != nil { 212 | return err 213 | } 214 | 215 | for name, u := range raw { 216 | var v map[string]any 217 | if err := u.Unmarshal(&v); err != nil { 218 | return fmt.Errorf("unable to unmarshal %q: %w", name, err) 219 | } 220 | 221 | // `authRequired` and `useClientOAuth` cannot be specified together 222 | if v["authRequired"] != nil && v["useClientOAuth"] == true { 223 | return fmt.Errorf("`authRequired` and `useClientOAuth` are mutually exclusive. Choose only one authentication method") 224 | } 225 | 226 | // Make `authRequired` an empty list instead of nil for Tool manifest 227 | if v["authRequired"] == nil { 228 | v["authRequired"] = []string{} 229 | } 230 | 231 | kindVal, ok := v["kind"] 232 | if !ok { 233 | return fmt.Errorf("missing 'kind' field for tool %q", name) 234 | } 235 | kindStr, ok := kindVal.(string) 236 | if !ok { 237 | return fmt.Errorf("invalid 'kind' field for tool %q (must be a string)", name) 238 | } 239 | 240 | yamlDecoder, err := util.NewStrictDecoder(v) 241 | if err != nil { 242 | return fmt.Errorf("error creating YAML decoder for tool %q: %w", name, err) 243 | } 244 | 245 | toolCfg, err := tools.DecodeConfig(ctx, kindStr, name, yamlDecoder) 246 | if err != nil { 247 | return err 248 | } 249 | (*c)[name] = toolCfg 250 | } 251 | return nil 252 | } 253 | 254 | // ToolConfigs is a type used to allow unmarshal of the toolset configs 255 | type ToolsetConfigs map[string]tools.ToolsetConfig 256 | 257 | // validate interface 258 | var _ yaml.InterfaceUnmarshalerContext = &ToolsetConfigs{} 259 | 260 | func (c *ToolsetConfigs) UnmarshalYAML(ctx context.Context, unmarshal func(interface{}) error) error { 261 | *c = make(ToolsetConfigs) 262 | 263 | var raw map[string][]string 264 | if err := unmarshal(&raw); err != nil { 265 | return err 266 | } 267 | 268 | for name, toolList := range raw { 269 | (*c)[name] = tools.ToolsetConfig{Name: name, ToolNames: toolList} 270 | } 271 | return nil 272 | } 273 | ``` -------------------------------------------------------------------------------- /internal/tools/sqlite/sqliteexecutesql/sqliteexecutesql_test.go: -------------------------------------------------------------------------------- ```go 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sqliteexecutesql_test 16 | 17 | import ( 18 | "context" 19 | "database/sql" 20 | "reflect" 21 | "testing" 22 | 23 | yaml "github.com/goccy/go-yaml" 24 | "github.com/google/go-cmp/cmp" 25 | "github.com/googleapis/genai-toolbox/internal/server" 26 | "github.com/googleapis/genai-toolbox/internal/testutils" 27 | "github.com/googleapis/genai-toolbox/internal/tools" 28 | "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqliteexecutesql" 29 | _ "modernc.org/sqlite" 30 | ) 31 | 32 | func TestParseFromYamlExecuteSql(t *testing.T) { 33 | ctx, err := testutils.ContextWithNewLogger() 34 | if err != nil { 35 | t.Fatalf("unexpected error: %s", err) 36 | } 37 | tcs := []struct { 38 | desc string 39 | in string 40 | want server.ToolConfigs 41 | }{ 42 | { 43 | desc: "basic example", 44 | in: ` 45 | tools: 46 | example_tool: 47 | kind: sqlite-execute-sql 48 | source: my-instance 49 | description: some description 50 | authRequired: 51 | - my-google-auth-service 52 | - other-auth-service 53 | `, 54 | want: server.ToolConfigs{ 55 | "example_tool": sqliteexecutesql.Config{ 56 | Name: "example_tool", 57 | Kind: "sqlite-execute-sql", 58 | Source: "my-instance", 59 | Description: "some description", 60 | AuthRequired: []string{"my-google-auth-service", "other-auth-service"}, 61 | }, 62 | }, 63 | }, 64 | } 65 | for _, tc := range tcs { 66 | t.Run(tc.desc, func(t *testing.T) { 67 | got := struct { 68 | Tools server.ToolConfigs `yaml:"tools"` 69 | }{} 70 | // Parse contents 71 | err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) 72 | if err != nil { 73 | t.Fatalf("unable to unmarshal: %s", err) 74 | } 75 | if diff := cmp.Diff(tc.want, got.Tools); diff != "" { 76 | t.Fatalf("incorrect parse: diff %v", diff) 77 | } 78 | }) 79 | } 80 | 81 | } 82 | 83 | func setupTestDB(t *testing.T) *sql.DB { 84 | db, err := sql.Open("sqlite", ":memory:") 85 | if err != nil { 86 | t.Fatalf("Failed to open in-memory database: %v", err) 87 | } 88 | return db 89 | } 90 | 91 | func TestTool_Invoke(t *testing.T) { 92 | ctx, err := testutils.ContextWithNewLogger() 93 | if err != nil { 94 | t.Fatalf("unexpected error: %s", err) 95 | } 96 | 97 | type fields struct { 98 | Name string 99 | Kind string 100 | AuthRequired []string 101 | Parameters tools.Parameters 102 | DB *sql.DB 103 | } 104 | type args struct { 105 | ctx context.Context 106 | params tools.ParamValues 107 | accessToken tools.AccessToken 108 | } 109 | tests := []struct { 110 | name string 111 | fields fields 112 | args args 113 | want any 114 | wantErr bool 115 | }{ 116 | { 117 | name: "create table", 118 | fields: fields{ 119 | DB: setupTestDB(t), 120 | }, 121 | args: args{ 122 | ctx: ctx, 123 | params: []tools.ParamValue{ 124 | {Name: "sql", Value: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"}, 125 | }, 126 | }, 127 | want: nil, 128 | wantErr: false, 129 | }, 130 | { 131 | name: "insert data", 132 | fields: fields{ 133 | DB: setupTestDB(t), 134 | }, 135 | args: args{ 136 | ctx: ctx, 137 | params: []tools.ParamValue{ 138 | {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)"}, 139 | }, 140 | }, 141 | want: nil, 142 | wantErr: false, 143 | }, 144 | { 145 | name: "select data", 146 | fields: fields{ 147 | DB: func() *sql.DB { 148 | db := setupTestDB(t) 149 | 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 { 150 | t.Fatalf("Failed to set up database for select: %v", err) 151 | } 152 | return db 153 | }(), 154 | }, 155 | args: args{ 156 | ctx: ctx, 157 | params: []tools.ParamValue{ 158 | {Name: "sql", Value: "SELECT * FROM users"}, 159 | }, 160 | }, 161 | want: []any{ 162 | map[string]any{"id": int64(1), "name": "Alice", "age": int64(30)}, 163 | map[string]any{"id": int64(2), "name": "Bob", "age": int64(25)}, 164 | }, 165 | wantErr: false, 166 | }, 167 | { 168 | name: "drop table", 169 | fields: fields{ 170 | DB: func() *sql.DB { 171 | db := setupTestDB(t) 172 | if _, err := db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"); err != nil { 173 | t.Fatalf("Failed to set up database for drop: %v", err) 174 | } 175 | return db 176 | }(), 177 | }, 178 | args: args{ 179 | ctx: ctx, 180 | params: []tools.ParamValue{ 181 | {Name: "sql", Value: "DROP TABLE users"}, 182 | }, 183 | }, 184 | want: nil, 185 | wantErr: false, 186 | }, 187 | { 188 | name: "invalid sql", 189 | fields: fields{ 190 | DB: setupTestDB(t), 191 | }, 192 | args: args{ 193 | ctx: ctx, 194 | params: []tools.ParamValue{ 195 | {Name: "sql", Value: "SELECT * FROM non_existent_table"}, 196 | }, 197 | }, 198 | want: nil, 199 | wantErr: true, 200 | }, 201 | { 202 | name: "empty sql", 203 | fields: fields{ 204 | DB: setupTestDB(t), 205 | }, 206 | args: args{ 207 | ctx: ctx, 208 | params: []tools.ParamValue{ 209 | {Name: "sql", Value: ""}, 210 | }, 211 | }, 212 | want: nil, 213 | wantErr: true, 214 | }, 215 | { 216 | name: "data types", 217 | fields: fields{ 218 | DB: func() *sql.DB { 219 | db := setupTestDB(t) 220 | if _, err := db.Exec("CREATE TABLE data_types (id INTEGER PRIMARY KEY, null_col TEXT, blob_col BLOB)"); err != nil { 221 | t.Fatalf("Failed to set up database for data types: %v", err) 222 | } 223 | if _, err := db.Exec("INSERT INTO data_types (id, null_col, blob_col) VALUES (1, NULL, ?)", []byte{1, 2, 3}); err != nil { 224 | t.Fatalf("Failed to insert data for data types: %v", err) 225 | } 226 | return db 227 | }(), 228 | }, 229 | args: args{ 230 | ctx: ctx, 231 | params: []tools.ParamValue{ 232 | {Name: "sql", Value: "SELECT * FROM data_types"}, 233 | }, 234 | }, 235 | want: []any{ 236 | map[string]any{"id": int64(1), "null_col": nil, "blob_col": []byte{1, 2, 3}}, 237 | }, 238 | wantErr: false, 239 | }, 240 | { 241 | name: "join operation", 242 | fields: fields{ 243 | DB: func() *sql.DB { 244 | db := setupTestDB(t) 245 | if _, err := db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"); err != nil { 246 | t.Fatalf("Failed to set up database for join: %v", err) 247 | } 248 | if _, err := db.Exec("INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30), (2, 'Bob', 25)"); err != nil { 249 | t.Fatalf("Failed to insert data for join: %v", err) 250 | } 251 | if _, err := db.Exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, item TEXT)"); err != nil { 252 | t.Fatalf("Failed to set up database for join: %v", err) 253 | } 254 | if _, err := db.Exec("INSERT INTO orders (id, user_id, item) VALUES (1, 1, 'Laptop'), (2, 2, 'Keyboard')"); err != nil { 255 | t.Fatalf("Failed to insert data for join: %v", err) 256 | } 257 | return db 258 | }(), 259 | }, 260 | args: args{ 261 | ctx: ctx, 262 | params: []tools.ParamValue{ 263 | {Name: "sql", Value: "SELECT u.name, o.item FROM users u JOIN orders o ON u.id = o.user_id"}, 264 | }, 265 | }, 266 | want: []any{ 267 | map[string]any{"name": "Alice", "item": "Laptop"}, 268 | map[string]any{"name": "Bob", "item": "Keyboard"}, 269 | }, 270 | wantErr: false, 271 | }, 272 | } 273 | for _, tt := range tests { 274 | t.Run(tt.name, func(t *testing.T) { 275 | tr := &sqliteexecutesql.Tool{ 276 | Name: tt.fields.Name, 277 | Kind: tt.fields.Kind, 278 | AuthRequired: tt.fields.AuthRequired, 279 | Parameters: tt.fields.Parameters, 280 | DB: tt.fields.DB, 281 | } 282 | got, err := tr.Invoke(tt.args.ctx, tt.args.params, tt.args.accessToken) 283 | if (err != nil) != tt.wantErr { 284 | t.Errorf("Tool.Invoke() error = %v, wantErr %v", err, tt.wantErr) 285 | return 286 | } 287 | isEqual := false 288 | if got != nil && len(got.([]any)) == 0 && len(tt.want.([]any)) == 0 { 289 | isEqual = true // Special case for empty slices, since DeepEqual returns false 290 | } else { 291 | isEqual = reflect.DeepEqual(got, tt.want) 292 | } 293 | 294 | if !isEqual { 295 | t.Errorf("Tool.Invoke() = %v, want %v", got, tt.want) 296 | } 297 | }) 298 | } 299 | } 300 | ``` -------------------------------------------------------------------------------- /docs/en/how-to/deploy_toolbox.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: "Deploy to Cloud Run" 3 | type: docs 4 | weight: 3 5 | description: > 6 | How to set up and configure Toolbox to run on Cloud Run. 7 | --- 8 | 9 | 10 | ## Before you begin 11 | 12 | 1. [Install](https://cloud.google.com/sdk/docs/install) the Google Cloud CLI. 13 | 14 | 1. Set the PROJECT_ID environment variable: 15 | 16 | ```bash 17 | export PROJECT_ID="my-project-id" 18 | ``` 19 | 20 | 1. Initialize gcloud CLI: 21 | 22 | ```bash 23 | gcloud init 24 | gcloud config set project $PROJECT_ID 25 | ``` 26 | 27 | 1. Make sure you've set up and initialized your database. 28 | 29 | 1. You must have the following APIs enabled: 30 | 31 | ```bash 32 | gcloud services enable run.googleapis.com \ 33 | cloudbuild.googleapis.com \ 34 | artifactregistry.googleapis.com \ 35 | iam.googleapis.com \ 36 | secretmanager.googleapis.com 37 | 38 | ``` 39 | 40 | 1. To create an IAM account, you must have the following IAM permissions (or 41 | roles): 42 | - Create Service Account role (roles/iam.serviceAccountCreator) 43 | 44 | 1. To create a secret, you must have the following roles: 45 | - Secret Manager Admin role (roles/secretmanager.admin) 46 | 47 | 1. To deploy to Cloud Run, you must have the following set of roles: 48 | - Cloud Run Developer (roles/run.developer) 49 | - Service Account User role (roles/iam.serviceAccountUser) 50 | 51 | {{< notice note >}} 52 | If you are using sources that require VPC-access (such as 53 | AlloyDB or Cloud SQL over private IP), make sure your Cloud Run service and the 54 | database are in the same VPC network. 55 | {{< /notice >}} 56 | 57 | ## Create a service account 58 | 59 | 1. Create a backend service account if you don't already have one: 60 | 61 | ```bash 62 | gcloud iam service-accounts create toolbox-identity 63 | ``` 64 | 65 | 1. Grant permissions to use secret manager: 66 | 67 | ```bash 68 | gcloud projects add-iam-policy-binding $PROJECT_ID \ 69 | --member serviceAccount:toolbox-identity@$PROJECT_ID.iam.gserviceaccount.com \ 70 | --role roles/secretmanager.secretAccessor 71 | ``` 72 | 73 | 1. Grant additional permissions to the service account that are specific to the 74 | source, e.g.: 75 | - [AlloyDB for PostgreSQL](../resources/sources/alloydb-pg.md#iam-permissions) 76 | - [Cloud SQL for PostgreSQL](../resources/sources/cloud-sql-pg.md#iam-permissions) 77 | 78 | ## Configure `tools.yaml` file 79 | 80 | Create a `tools.yaml` file that contains your configuration for Toolbox. For 81 | details, see the 82 | [configuration](../resources/sources/) 83 | section. 84 | 85 | ## Deploy to Cloud Run 86 | 87 | 1. Upload `tools.yaml` as a secret: 88 | 89 | ```bash 90 | gcloud secrets create tools --data-file=tools.yaml 91 | ``` 92 | 93 | If you already have a secret and want to update the secret version, execute 94 | the following: 95 | 96 | ```bash 97 | gcloud secrets versions add tools --data-file=tools.yaml 98 | ``` 99 | 100 | 1. Set an environment variable to the container image that you want to use for 101 | cloud run: 102 | 103 | ```bash 104 | export IMAGE=us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest 105 | ``` 106 | 107 | {{< notice note >}} 108 | **The `$PORT` Environment Variable** 109 | Google Cloud Run dictates the port your application must listen on by setting 110 | the `$PORT` environment variable inside your container. This value defaults to 111 | **8080**. Your application's `--port` argument **must** be set to listen on this 112 | port. If there is a mismatch, the container will fail to start and the 113 | deployment will time out. 114 | {{< /notice >}} 115 | 116 | 1. Deploy Toolbox to Cloud Run using the following command: 117 | 118 | ```bash 119 | gcloud run deploy toolbox \ 120 | --image $IMAGE \ 121 | --service-account toolbox-identity \ 122 | --region us-central1 \ 123 | --set-secrets "/app/tools.yaml=tools:latest" \ 124 | --args="--tools-file=/app/tools.yaml","--address=0.0.0.0","--port=8080" 125 | # --allow-unauthenticated # https://cloud.google.com/run/docs/authenticating/public#gcloud 126 | ``` 127 | 128 | If you are using a VPC network, use the command below: 129 | 130 | ```bash 131 | gcloud run deploy toolbox \ 132 | --image $IMAGE \ 133 | --service-account toolbox-identity \ 134 | --region us-central1 \ 135 | --set-secrets "/app/tools.yaml=tools:latest" \ 136 | --args="--tools-file=/app/tools.yaml","--address=0.0.0.0","--port=8080" \ 137 | # TODO(dev): update the following to match your VPC if necessary 138 | --network default \ 139 | --subnet default 140 | # --allow-unauthenticated # https://cloud.google.com/run/docs/authenticating/public#gcloud 141 | ``` 142 | 143 | ## Connecting with Toolbox Client SDK 144 | 145 | You can connect to Toolbox Cloud Run instances directly through the SDK. 146 | 147 | 1. [Set up `Cloud Run Invoker` role 148 | access](https://cloud.google.com/run/docs/securing/managing-access#service-add-principals) 149 | to your Cloud Run service. 150 | 151 | 1. (Only for local runs) Set up [Application Default 152 | Credentials](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment) 153 | for the principal you set up the `Cloud Run Invoker` role access to. 154 | 155 | 1. Run the following to retrieve a non-deterministic URL for the cloud run service: 156 | 157 | ```bash 158 | gcloud run services describe toolbox --format 'value(status.url)' 159 | ``` 160 | 161 | 1. Import and initialize the toolbox client with the URL retrieved above: 162 | 163 | {{< tabpane persist=header >}} 164 | {{< tab header="Python" lang="python" >}} 165 | from toolbox_core import ToolboxClient, auth_methods 166 | 167 | # Replace with the Cloud Run service URL generated in the previous step. 168 | URL = "https://cloud-run-url.app" 169 | 170 | auth_token_provider = auth_methods.aget_google_id_token(URL) # can also use sync method 171 | 172 | async with ToolboxClient( 173 | URL, 174 | client_headers={"Authorization": auth_token_provider}, 175 | ) as toolbox: 176 | {{< /tab >}} 177 | {{< tab header="Javascript" lang="javascript" >}} 178 | import { ToolboxClient } from '@toolbox-sdk/core'; 179 | import {getGoogleIdToken} from '@toolbox-sdk/core/auth' 180 | 181 | // Replace with the Cloud Run service URL generated in the previous step. 182 | const URL = 'http://127.0.0.1:5000'; 183 | const authTokenProvider = () => getGoogleIdToken(URL); 184 | 185 | const client = new ToolboxClient(URL, null, {"Authorization": authTokenProvider}); 186 | {{< /tab >}} 187 | {{< tab header="Go" lang="go" >}} 188 | import "github.com/googleapis/mcp-toolbox-sdk-go/core" 189 | 190 | func main() { 191 | // Replace with the Cloud Run service URL generated in the previous step. 192 | URL := "http://127.0.0.1:5000" 193 | auth_token_provider, err := core.GetGoogleIDToken(ctx, URL) 194 | if err != nil { 195 | log.Fatalf("Failed to fetch token %v", err) 196 | } 197 | toolboxClient, err := core.NewToolboxClient( 198 | URL, 199 | core.WithClientHeaderString("Authorization", auth_token_provider)) 200 | if err != nil { 201 | log.Fatalf("Failed to create Toolbox client: %v", err) 202 | } 203 | } 204 | {{< /tab >}} 205 | {{< /tabpane >}} 206 | 207 | 208 | Now, you can use this client to connect to the deployed Cloud Run instance! 209 | 210 | ## Troubleshooting 211 | 212 | {{< notice note >}} 213 | For any deployment or runtime error, the best first step is to check the logs 214 | for your service in the Google Cloud Console's Cloud Run section. They often 215 | contain the specific error message needed to diagnose the problem. 216 | {{< /notice >}} 217 | 218 | * **Deployment Fails with "Container failed to start":** This is almost always 219 | caused by a port mismatch. Ensure your container's `--port` argument is set to 220 | `8080` to match the `$PORT` environment variable provided by Cloud Run. 221 | 222 | * **Client Receives Permission Denied Error (401 or 403):** If your client 223 | application (e.g., your local SDK) gets a `401 Unauthorized` or `403 224 | Forbidden` error when trying to call your Cloud Run service, it means the 225 | client is not properly authenticated as an invoker. 226 | * Ensure the user or service account calling the service has the **Cloud Run 227 | Invoker** (`roles/run.invoker`) IAM role. 228 | * If running locally, make sure your Application Default Credentials are set 229 | up correctly by running `gcloud auth application-default login`. 230 | 231 | * **Service Fails to Access Secrets (in logs):** If your application starts but 232 | the logs show errors like "permission denied" when trying to access Secret 233 | Manager, it means the Toolbox service account is missing permissions. 234 | * Ensure the `toolbox-identity` service account has the **Secret Manager 235 | Secret Accessor** (`roles/secretmanager.secretAccessor`) IAM role. ```