This is page 29 of 45. 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-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-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 │ │ ├── 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 │ │ │ ├── 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 │ │ │ ├── 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 │ │ ├── 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 -------------------------------------------------------------------------------- /tests/alloydbainl/alloydb_ai_nl_integration_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 alloydbainl 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "encoding/json" 21 | "io" 22 | "net/http" 23 | "os" 24 | "reflect" 25 | "regexp" 26 | "strings" 27 | "testing" 28 | "time" 29 | 30 | "github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc" 31 | "github.com/googleapis/genai-toolbox/internal/testutils" 32 | "github.com/googleapis/genai-toolbox/tests" 33 | ) 34 | 35 | var ( 36 | AlloyDBAINLSourceKind = "alloydb-postgres" 37 | AlloyDBAINLToolKind = "alloydb-ai-nl" 38 | AlloyDBAINLProject = os.Getenv("ALLOYDB_AI_NL_PROJECT") 39 | AlloyDBAINLRegion = os.Getenv("ALLOYDB_AI_NL_REGION") 40 | AlloyDBAINLCluster = os.Getenv("ALLOYDB_AI_NL_CLUSTER") 41 | AlloyDBAINLInstance = os.Getenv("ALLOYDB_AI_NL_INSTANCE") 42 | AlloyDBAINLDatabase = os.Getenv("ALLOYDB_AI_NL_DATABASE") 43 | AlloyDBAINLUser = os.Getenv("ALLOYDB_AI_NL_USER") 44 | AlloyDBAINLPass = os.Getenv("ALLOYDB_AI_NL_PASS") 45 | ) 46 | 47 | func getAlloyDBAINLVars(t *testing.T) map[string]any { 48 | switch "" { 49 | case AlloyDBAINLProject: 50 | t.Fatal("'ALLOYDB_AI_NL_PROJECT' not set") 51 | case AlloyDBAINLRegion: 52 | t.Fatal("'ALLOYDB_AI_NL_REGION' not set") 53 | case AlloyDBAINLCluster: 54 | t.Fatal("'ALLOYDB_AI_NL_CLUSTER' not set") 55 | case AlloyDBAINLInstance: 56 | t.Fatal("'ALLOYDB_AI_NL_INSTANCE' not set") 57 | case AlloyDBAINLDatabase: 58 | t.Fatal("'ALLOYDB_AI_NL_DATABASE' not set") 59 | case AlloyDBAINLUser: 60 | t.Fatal("'ALLOYDB_AI_NL_USER' not set") 61 | case AlloyDBAINLPass: 62 | t.Fatal("'ALLOYDB_AI_NL_PASS' not set") 63 | } 64 | return map[string]any{ 65 | "kind": AlloyDBAINLSourceKind, 66 | "project": AlloyDBAINLProject, 67 | "cluster": AlloyDBAINLCluster, 68 | "instance": AlloyDBAINLInstance, 69 | "region": AlloyDBAINLRegion, 70 | "database": AlloyDBAINLDatabase, 71 | "user": AlloyDBAINLUser, 72 | "password": AlloyDBAINLPass, 73 | } 74 | } 75 | 76 | func TestAlloyDBAINLToolEndpoints(t *testing.T) { 77 | sourceConfig := getAlloyDBAINLVars(t) 78 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 79 | defer cancel() 80 | 81 | var args []string 82 | 83 | // Write config into a file and pass it to command 84 | toolsFile := getAINLToolsConfig(sourceConfig) 85 | 86 | cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) 87 | if err != nil { 88 | t.Fatalf("command initialization returned an error: %s", err) 89 | } 90 | defer cleanup() 91 | 92 | waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 93 | defer cancel() 94 | out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out) 95 | if err != nil { 96 | t.Logf("toolbox command logs: \n%s", out) 97 | t.Fatalf("toolbox didn't start successfully: %s", err) 98 | } 99 | 100 | runAINLToolGetTest(t) 101 | runAINLToolInvokeTest(t) 102 | runAINLMCPToolCallMethod(t) 103 | } 104 | 105 | func runAINLToolGetTest(t *testing.T) { 106 | // Test tool get endpoint 107 | tcs := []struct { 108 | name string 109 | api string 110 | want map[string]any 111 | }{ 112 | { 113 | name: "get my-simple-tool", 114 | api: "http://127.0.0.1:5000/api/tool/my-simple-tool/", 115 | want: map[string]any{ 116 | "my-simple-tool": map[string]any{ 117 | "description": "Simple tool to test end to end functionality.", 118 | "parameters": []any{ 119 | map[string]any{ 120 | "name": "question", 121 | "type": "string", 122 | "required": true, 123 | "description": "The natural language question to ask.", 124 | "authSources": []any{}, 125 | }, 126 | }, 127 | "authRequired": []any{}, 128 | }, 129 | }, 130 | }, 131 | } 132 | for _, tc := range tcs { 133 | t.Run(tc.name, func(t *testing.T) { 134 | resp, err := http.Get(tc.api) 135 | if err != nil { 136 | t.Fatalf("error when sending a request: %s", err) 137 | } 138 | defer resp.Body.Close() 139 | if resp.StatusCode != 200 { 140 | t.Fatalf("response status code is not 200") 141 | } 142 | 143 | var body map[string]interface{} 144 | err = json.NewDecoder(resp.Body).Decode(&body) 145 | if err != nil { 146 | t.Fatalf("error parsing response body") 147 | } 148 | 149 | got, ok := body["tools"] 150 | if !ok { 151 | t.Fatalf("unable to find tools in response body") 152 | } 153 | if !reflect.DeepEqual(got, tc.want) { 154 | t.Fatalf("got %q, want %q", got, tc.want) 155 | } 156 | }) 157 | } 158 | } 159 | 160 | func runAINLToolInvokeTest(t *testing.T) { 161 | // Get ID token 162 | idToken, err := tests.GetGoogleIdToken(tests.ClientId) 163 | if err != nil { 164 | t.Fatalf("error getting Google ID token: %s", err) 165 | } 166 | 167 | // Test tool invoke endpoint 168 | invokeTcs := []struct { 169 | name string 170 | api string 171 | requestHeader map[string]string 172 | requestBody io.Reader 173 | want string 174 | isErr bool 175 | }{ 176 | { 177 | name: "invoke my-simple-tool", 178 | api: "http://127.0.0.1:5000/api/tool/my-simple-tool/invoke", 179 | requestHeader: map[string]string{}, 180 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 181 | want: "[{\"execute_nl_query\":{\"?column?\":1}}]", 182 | isErr: false, 183 | }, 184 | { 185 | name: "Invoke my-tool without parameters", 186 | api: "http://127.0.0.1:5000/api/tool/my-tool/invoke", 187 | requestHeader: map[string]string{}, 188 | requestBody: bytes.NewBuffer([]byte(`{}`)), 189 | isErr: true, 190 | }, 191 | { 192 | name: "Invoke my-auth-tool with auth token", 193 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke", 194 | requestHeader: map[string]string{"my-google-auth_token": idToken}, 195 | requestBody: bytes.NewBuffer([]byte(`{"question": "can you show me the name of this user?"}`)), 196 | want: "[{\"execute_nl_query\":{\"name\":\"Alice\"}}]", 197 | isErr: false, 198 | }, 199 | { 200 | name: "Invoke my-auth-tool with invalid auth token", 201 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke", 202 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"}, 203 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 204 | isErr: true, 205 | }, 206 | { 207 | name: "Invoke my-auth-tool without auth token", 208 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke", 209 | requestHeader: map[string]string{}, 210 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 211 | isErr: true, 212 | }, 213 | { 214 | name: "Invoke my-auth-required-tool with auth token", 215 | api: "http://127.0.0.1:5000/api/tool/my-auth-required-tool/invoke", 216 | requestHeader: map[string]string{"my-google-auth_token": idToken}, 217 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 218 | isErr: false, 219 | want: "[{\"execute_nl_query\":{\"?column?\":1}}]", 220 | }, 221 | { 222 | name: "Invoke my-auth-required-tool with invalid auth token", 223 | api: "http://127.0.0.1:5000/api/tool/my-auth-required-tool/invoke", 224 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"}, 225 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 226 | isErr: true, 227 | }, 228 | { 229 | name: "Invoke my-auth-required-tool without auth token", 230 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke", 231 | requestHeader: map[string]string{}, 232 | requestBody: bytes.NewBuffer([]byte(`{"question": "return 1"}`)), 233 | isErr: true, 234 | }, 235 | } 236 | for _, tc := range invokeTcs { 237 | t.Run(tc.name, func(t *testing.T) { 238 | // Send Tool invocation request 239 | req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody) 240 | if err != nil { 241 | t.Fatalf("unable to create request: %s", err) 242 | } 243 | req.Header.Add("Content-type", "application/json") 244 | for k, v := range tc.requestHeader { 245 | req.Header.Add(k, v) 246 | } 247 | resp, err := http.DefaultClient.Do(req) 248 | if err != nil { 249 | t.Fatalf("unable to send request: %s", err) 250 | } 251 | defer resp.Body.Close() 252 | 253 | if resp.StatusCode != http.StatusOK { 254 | if tc.isErr == true { 255 | return 256 | } 257 | bodyBytes, _ := io.ReadAll(resp.Body) 258 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes)) 259 | } 260 | 261 | // Check response body 262 | var body map[string]interface{} 263 | err = json.NewDecoder(resp.Body).Decode(&body) 264 | if err != nil { 265 | t.Fatalf("error parsing response body") 266 | } 267 | got, ok := body["result"].(string) 268 | if !ok { 269 | t.Fatalf("unable to find result in response body") 270 | } 271 | 272 | if got != tc.want { 273 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want) 274 | } 275 | }) 276 | } 277 | 278 | } 279 | 280 | func getAINLToolsConfig(sourceConfig map[string]any) map[string]any { 281 | // Write config into a file and pass it to command 282 | toolsFile := map[string]any{ 283 | "sources": map[string]any{ 284 | "my-instance": sourceConfig, 285 | }, 286 | "authServices": map[string]any{ 287 | "my-google-auth": map[string]any{ 288 | "kind": "google", 289 | "clientId": tests.ClientId, 290 | }, 291 | }, 292 | "tools": map[string]any{ 293 | "my-simple-tool": map[string]any{ 294 | "kind": AlloyDBAINLToolKind, 295 | "source": "my-instance", 296 | "description": "Simple tool to test end to end functionality.", 297 | "nlConfig": "my_nl_config", 298 | }, 299 | "my-auth-tool": map[string]any{ 300 | "kind": AlloyDBAINLToolKind, 301 | "source": "my-instance", 302 | "description": "Tool to test authenticated parameters.", 303 | "nlConfig": "my_nl_config", 304 | "nlConfigParameters": []map[string]any{ 305 | { 306 | "name": "email", 307 | "type": "string", 308 | "description": "user email", 309 | "authServices": []map[string]string{ 310 | { 311 | "name": "my-google-auth", 312 | "field": "email", 313 | }, 314 | }, 315 | }, 316 | }, 317 | }, 318 | "my-auth-required-tool": map[string]any{ 319 | "kind": AlloyDBAINLToolKind, 320 | "source": "my-instance", 321 | "description": "Tool to test auth required invocation.", 322 | "nlConfig": "my_nl_config", 323 | "authRequired": []string{ 324 | "my-google-auth", 325 | }, 326 | }, 327 | }, 328 | } 329 | 330 | return toolsFile 331 | } 332 | 333 | func runAINLMCPToolCallMethod(t *testing.T) { 334 | sessionId := tests.RunInitialize(t, "2024-11-05") 335 | header := map[string]string{} 336 | if sessionId != "" { 337 | header["Mcp-Session-Id"] = sessionId 338 | } 339 | 340 | // Test tool invoke endpoint 341 | invokeTcs := []struct { 342 | name string 343 | api string 344 | requestBody jsonrpc.JSONRPCRequest 345 | requestHeader map[string]string 346 | want string 347 | }{ 348 | { 349 | name: "MCP Invoke my-simple-tool", 350 | api: "http://127.0.0.1:5000/mcp", 351 | requestHeader: map[string]string{}, 352 | requestBody: jsonrpc.JSONRPCRequest{ 353 | Jsonrpc: "2.0", 354 | Id: "my-simple-tool", 355 | Request: jsonrpc.Request{ 356 | Method: "tools/call", 357 | }, 358 | Params: map[string]any{ 359 | "name": "my-simple-tool", 360 | "arguments": map[string]any{ 361 | "question": "return 1", 362 | }, 363 | }, 364 | }, 365 | want: `{"jsonrpc":"2.0","id":"my-simple-tool","result":{"content":[{"type":"text","text":"{\"execute_nl_query\":{\"?column?\":1}}"}]}}`, 366 | }, 367 | { 368 | name: "MCP Invoke invalid tool", 369 | api: "http://127.0.0.1:5000/mcp", 370 | requestHeader: map[string]string{}, 371 | requestBody: jsonrpc.JSONRPCRequest{ 372 | Jsonrpc: "2.0", 373 | Id: "invalid-tool", 374 | Request: jsonrpc.Request{ 375 | Method: "tools/call", 376 | }, 377 | Params: map[string]any{ 378 | "name": "foo", 379 | "arguments": map[string]any{}, 380 | }, 381 | }, 382 | want: `{"jsonrpc":"2.0","id":"invalid-tool","error":{"code":-32602,"message":"invalid tool name: tool with name \"foo\" does not exist"}}`, 383 | }, 384 | { 385 | name: "MCP Invoke my-auth-tool without parameters", 386 | api: "http://127.0.0.1:5000/mcp", 387 | requestHeader: map[string]string{}, 388 | requestBody: jsonrpc.JSONRPCRequest{ 389 | Jsonrpc: "2.0", 390 | Id: "invoke-without-parameter", 391 | Request: jsonrpc.Request{ 392 | Method: "tools/call", 393 | }, 394 | Params: map[string]any{ 395 | "name": "my-auth-tool", 396 | "arguments": map[string]any{}, 397 | }, 398 | }, 399 | want: `{"jsonrpc":"2.0","id":"invoke-without-parameter","error":{"code":-32602,"message":"provided parameters were invalid: parameter question is required"}}`, 400 | }, 401 | } 402 | for _, tc := range invokeTcs { 403 | t.Run(tc.name, func(t *testing.T) { 404 | reqMarshal, err := json.Marshal(tc.requestBody) 405 | if err != nil { 406 | t.Fatalf("unexpected error during marshaling of request body") 407 | } 408 | // Send Tool invocation request 409 | req, err := http.NewRequest(http.MethodPost, tc.api, bytes.NewBuffer(reqMarshal)) 410 | if err != nil { 411 | t.Fatalf("unable to create request: %s", err) 412 | } 413 | req.Header.Add("Content-type", "application/json") 414 | for k, v := range header { 415 | req.Header.Add(k, v) 416 | } 417 | resp, err := http.DefaultClient.Do(req) 418 | if err != nil { 419 | t.Fatalf("unable to send request: %s", err) 420 | } 421 | respBody, err := io.ReadAll(resp.Body) 422 | if err != nil { 423 | t.Fatalf("unable to read request body: %s", err) 424 | } 425 | defer resp.Body.Close() 426 | got := string(bytes.TrimSpace(respBody)) 427 | 428 | // Remove `\` and `"` for string comparison 429 | got = strings.ReplaceAll(got, "\\", "") 430 | want := strings.ReplaceAll(tc.want, "\\", "") 431 | got = strings.ReplaceAll(got, "\"", "") 432 | want = strings.ReplaceAll(want, "\"", "") 433 | 434 | if !strings.Contains(got, want) { 435 | t.Fatalf("Expected substring not found:\ngot: %q\nwant: %q (to be contained within got)", got, want) 436 | } 437 | }) 438 | } 439 | } 440 | ``` -------------------------------------------------------------------------------- /internal/tools/bigquery/bigqueryexecutesql/bigqueryexecutesql.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 bigqueryexecutesql 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "fmt" 21 | "strings" 22 | 23 | bigqueryapi "cloud.google.com/go/bigquery" 24 | yaml "github.com/goccy/go-yaml" 25 | "github.com/googleapis/genai-toolbox/internal/sources" 26 | bigqueryds "github.com/googleapis/genai-toolbox/internal/sources/bigquery" 27 | "github.com/googleapis/genai-toolbox/internal/tools" 28 | bqutil "github.com/googleapis/genai-toolbox/internal/tools/bigquery/bigquerycommon" 29 | "github.com/googleapis/genai-toolbox/internal/util" 30 | bigqueryrestapi "google.golang.org/api/bigquery/v2" 31 | "google.golang.org/api/iterator" 32 | ) 33 | 34 | const kind string = "bigquery-execute-sql" 35 | 36 | func init() { 37 | if !tools.Register(kind, newConfig) { 38 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 39 | } 40 | } 41 | 42 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 43 | actual := Config{Name: name} 44 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 45 | return nil, err 46 | } 47 | return actual, nil 48 | } 49 | 50 | type compatibleSource interface { 51 | BigQueryClient() *bigqueryapi.Client 52 | BigQuerySession() bigqueryds.BigQuerySessionProvider 53 | BigQueryWriteMode() string 54 | BigQueryRestService() *bigqueryrestapi.Service 55 | BigQueryClientCreator() bigqueryds.BigqueryClientCreator 56 | UseClientAuthorization() bool 57 | IsDatasetAllowed(projectID, datasetID string) bool 58 | BigQueryAllowedDatasets() []string 59 | } 60 | 61 | // validate compatible sources are still compatible 62 | var _ compatibleSource = &bigqueryds.Source{} 63 | 64 | var compatibleSources = [...]string{bigqueryds.SourceKind} 65 | 66 | type Config struct { 67 | Name string `yaml:"name" validate:"required"` 68 | Kind string `yaml:"kind" validate:"required"` 69 | Source string `yaml:"source" validate:"required"` 70 | Description string `yaml:"description" validate:"required"` 71 | AuthRequired []string `yaml:"authRequired"` 72 | } 73 | 74 | // validate interface 75 | var _ tools.ToolConfig = Config{} 76 | 77 | func (cfg Config) ToolConfigKind() string { 78 | return kind 79 | } 80 | 81 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 82 | // verify source exists 83 | rawS, ok := srcs[cfg.Source] 84 | if !ok { 85 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 86 | } 87 | 88 | // verify the source is compatible 89 | s, ok := rawS.(compatibleSource) 90 | if !ok { 91 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) 92 | } 93 | 94 | var sqlDescriptionBuilder strings.Builder 95 | switch s.BigQueryWriteMode() { 96 | case bigqueryds.WriteModeBlocked: 97 | sqlDescriptionBuilder.WriteString("The SQL to execute. In 'blocked' mode, only SELECT statements are allowed; other statement types will fail.") 98 | case bigqueryds.WriteModeProtected: 99 | sqlDescriptionBuilder.WriteString("The SQL to execute. Only SELECT statements and writes to the session's temporary dataset are allowed (e.g., `CREATE TEMP TABLE ...`).") 100 | default: // WriteModeAllowed 101 | sqlDescriptionBuilder.WriteString("The SQL to execute.") 102 | } 103 | 104 | allowedDatasets := s.BigQueryAllowedDatasets() 105 | if len(allowedDatasets) > 0 { 106 | if len(allowedDatasets) == 1 { 107 | datasetFQN := allowedDatasets[0] 108 | parts := strings.Split(datasetFQN, ".") 109 | if len(parts) < 2 { 110 | return nil, fmt.Errorf("expected allowedDataset to have at least 2 parts (project.dataset): %s", datasetFQN) 111 | } 112 | datasetID := parts[1] 113 | sqlDescriptionBuilder.WriteString(fmt.Sprintf(" The query must only access the `%s` dataset. "+ 114 | "To query a table within this dataset (e.g., `my_table`), "+ 115 | "qualify it with the dataset id (e.g., `%s.my_table`).", datasetFQN, datasetID)) 116 | } else { 117 | datasetIDs := []string{} 118 | for _, ds := range allowedDatasets { 119 | datasetIDs = append(datasetIDs, fmt.Sprintf("`%s`", ds)) 120 | } 121 | sqlDescriptionBuilder.WriteString(fmt.Sprintf(" The query must only access datasets from the following list: %s.", strings.Join(datasetIDs, ", "))) 122 | } 123 | } 124 | 125 | sqlParameter := tools.NewStringParameter("sql", sqlDescriptionBuilder.String()) 126 | dryRunParameter := tools.NewBooleanParameterWithDefault( 127 | "dry_run", 128 | false, 129 | "If set to true, the query will be validated and information about the execution will be returned "+ 130 | "without running the query. Defaults to false.", 131 | ) 132 | parameters := tools.Parameters{sqlParameter, dryRunParameter} 133 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) 134 | 135 | // finish tool setup 136 | t := Tool{ 137 | Name: cfg.Name, 138 | Kind: kind, 139 | Parameters: parameters, 140 | AuthRequired: cfg.AuthRequired, 141 | UseClientOAuth: s.UseClientAuthorization(), 142 | ClientCreator: s.BigQueryClientCreator(), 143 | Client: s.BigQueryClient(), 144 | RestService: s.BigQueryRestService(), 145 | WriteMode: s.BigQueryWriteMode(), 146 | SessionProvider: s.BigQuerySession(), 147 | IsDatasetAllowed: s.IsDatasetAllowed, 148 | AllowedDatasets: allowedDatasets, 149 | manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired}, 150 | mcpManifest: mcpManifest, 151 | } 152 | return t, nil 153 | } 154 | 155 | // validate interface 156 | var _ tools.Tool = Tool{} 157 | 158 | type Tool struct { 159 | Name string `yaml:"name"` 160 | Kind string `yaml:"kind"` 161 | AuthRequired []string `yaml:"authRequired"` 162 | UseClientOAuth bool `yaml:"useClientOAuth"` 163 | Parameters tools.Parameters `yaml:"parameters"` 164 | 165 | Client *bigqueryapi.Client 166 | RestService *bigqueryrestapi.Service 167 | WriteMode string 168 | SessionProvider bigqueryds.BigQuerySessionProvider 169 | ClientCreator bigqueryds.BigqueryClientCreator 170 | IsDatasetAllowed func(projectID, datasetID string) bool 171 | AllowedDatasets []string 172 | manifest tools.Manifest 173 | mcpManifest tools.McpManifest 174 | } 175 | 176 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 177 | paramsMap := params.AsMap() 178 | sql, ok := paramsMap["sql"].(string) 179 | if !ok { 180 | return nil, fmt.Errorf("unable to cast sql parameter %s", paramsMap["sql"]) 181 | } 182 | dryRun, ok := paramsMap["dry_run"].(bool) 183 | if !ok { 184 | return nil, fmt.Errorf("unable to cast dry_run parameter %s", paramsMap["dry_run"]) 185 | } 186 | 187 | bqClient := t.Client 188 | restService := t.RestService 189 | 190 | var err error 191 | // Initialize new client if using user OAuth token 192 | if t.UseClientOAuth { 193 | tokenStr, err := accessToken.ParseBearerToken() 194 | if err != nil { 195 | return nil, fmt.Errorf("error parsing access token: %w", err) 196 | } 197 | bqClient, restService, err = t.ClientCreator(tokenStr, true) 198 | if err != nil { 199 | return nil, fmt.Errorf("error creating client from OAuth access token: %w", err) 200 | } 201 | } 202 | 203 | var connProps []*bigqueryapi.ConnectionProperty 204 | var session *bigqueryds.Session 205 | if t.WriteMode == bigqueryds.WriteModeProtected { 206 | session, err = t.SessionProvider(ctx) 207 | if err != nil { 208 | return nil, fmt.Errorf("failed to get BigQuery session for protected mode: %w", err) 209 | } 210 | connProps = []*bigqueryapi.ConnectionProperty{ 211 | {Key: "session_id", Value: session.ID}, 212 | } 213 | } 214 | 215 | dryRunJob, err := bqutil.DryRunQuery(ctx, restService, bqClient.Project(), bqClient.Location, sql, nil, connProps) 216 | if err != nil { 217 | return nil, fmt.Errorf("query validation failed: %w", err) 218 | } 219 | 220 | statementType := dryRunJob.Statistics.Query.StatementType 221 | 222 | switch t.WriteMode { 223 | case bigqueryds.WriteModeBlocked: 224 | if statementType != "SELECT" { 225 | return nil, fmt.Errorf("write mode is 'blocked', only SELECT statements are allowed") 226 | } 227 | case bigqueryds.WriteModeProtected: 228 | if dryRunJob.Configuration != nil && dryRunJob.Configuration.Query != nil { 229 | if dest := dryRunJob.Configuration.Query.DestinationTable; dest != nil && dest.DatasetId != session.DatasetID { 230 | return nil, fmt.Errorf("protected write mode only supports SELECT statements, or write operations in the anonymous "+ 231 | "dataset of a BigQuery session, but destination was %q", dest.DatasetId) 232 | } 233 | } 234 | } 235 | 236 | if len(t.AllowedDatasets) > 0 { 237 | switch statementType { 238 | case "CREATE_SCHEMA", "DROP_SCHEMA", "ALTER_SCHEMA": 239 | return nil, fmt.Errorf("dataset-level operations like '%s' are not allowed when dataset restrictions are in place", statementType) 240 | case "CREATE_FUNCTION", "CREATE_TABLE_FUNCTION", "CREATE_PROCEDURE": 241 | return nil, fmt.Errorf("creating stored routines ('%s') is not allowed when dataset restrictions are in place, as their contents cannot be safely analyzed", statementType) 242 | case "CALL": 243 | return nil, fmt.Errorf("calling stored procedures ('%s') is not allowed when dataset restrictions are in place, as their contents cannot be safely analyzed", statementType) 244 | } 245 | 246 | // Use a map to avoid duplicate table names. 247 | tableIDSet := make(map[string]struct{}) 248 | 249 | // Get all tables from the dry run result. This is the most reliable method. 250 | queryStats := dryRunJob.Statistics.Query 251 | if queryStats != nil { 252 | for _, tableRef := range queryStats.ReferencedTables { 253 | tableIDSet[fmt.Sprintf("%s.%s.%s", tableRef.ProjectId, tableRef.DatasetId, tableRef.TableId)] = struct{}{} 254 | } 255 | if tableRef := queryStats.DdlTargetTable; tableRef != nil { 256 | tableIDSet[fmt.Sprintf("%s.%s.%s", tableRef.ProjectId, tableRef.DatasetId, tableRef.TableId)] = struct{}{} 257 | } 258 | if tableRef := queryStats.DdlDestinationTable; tableRef != nil { 259 | tableIDSet[fmt.Sprintf("%s.%s.%s", tableRef.ProjectId, tableRef.DatasetId, tableRef.TableId)] = struct{}{} 260 | } 261 | } 262 | 263 | var tableNames []string 264 | if len(tableIDSet) > 0 { 265 | for tableID := range tableIDSet { 266 | tableNames = append(tableNames, tableID) 267 | } 268 | } else if statementType != "SELECT" { 269 | // If dry run yields no tables, fall back to the parser for non-SELECT statements 270 | // to catch unsafe operations like EXECUTE IMMEDIATE. 271 | parsedTables, parseErr := bqutil.TableParser(sql, t.Client.Project()) 272 | if parseErr != nil { 273 | // If parsing fails (e.g., EXECUTE IMMEDIATE), we cannot guarantee safety, so we must fail. 274 | return nil, fmt.Errorf("could not parse tables from query to validate against allowed datasets: %w", parseErr) 275 | } 276 | tableNames = parsedTables 277 | } 278 | 279 | for _, tableID := range tableNames { 280 | parts := strings.Split(tableID, ".") 281 | if len(parts) == 3 { 282 | projectID, datasetID := parts[0], parts[1] 283 | if !t.IsDatasetAllowed(projectID, datasetID) { 284 | return nil, fmt.Errorf("query accesses dataset '%s.%s', which is not in the allowed list", projectID, datasetID) 285 | } 286 | } 287 | } 288 | } 289 | 290 | if dryRun { 291 | if dryRunJob != nil { 292 | jobJSON, err := json.MarshalIndent(dryRunJob, "", " ") 293 | if err != nil { 294 | return nil, fmt.Errorf("failed to marshal dry run job to JSON: %w", err) 295 | } 296 | return string(jobJSON), nil 297 | } 298 | // This case should not be reached, but as a fallback, we return a message. 299 | return "Dry run was requested, but no job information was returned.", nil 300 | } 301 | 302 | query := bqClient.Query(sql) 303 | query.Location = bqClient.Location 304 | 305 | query.ConnectionProperties = connProps 306 | 307 | // Log the query executed for debugging. 308 | logger, err := util.LoggerFromContext(ctx) 309 | if err != nil { 310 | return nil, fmt.Errorf("error getting logger: %s", err) 311 | } 312 | logger.DebugContext(ctx, "executing `%s` tool query: %s", kind, sql) 313 | 314 | // This block handles SELECT statements, which return a row set. 315 | // We iterate through the results, convert each row into a map of 316 | // column names to values, and return the collection of rows. 317 | var out []any 318 | job, err := query.Run(ctx) 319 | if err != nil { 320 | return nil, fmt.Errorf("unable to execute query: %w", err) 321 | } 322 | it, err := job.Read(ctx) 323 | if err != nil { 324 | return nil, fmt.Errorf("unable to read query results: %w", err) 325 | } 326 | for { 327 | var row map[string]bigqueryapi.Value 328 | err = it.Next(&row) 329 | if err == iterator.Done { 330 | break 331 | } 332 | if err != nil { 333 | return nil, fmt.Errorf("unable to iterate through query results: %w", err) 334 | } 335 | vMap := make(map[string]any) 336 | for key, value := range row { 337 | vMap[key] = value 338 | } 339 | out = append(out, vMap) 340 | } 341 | // If the query returned any rows, return them directly. 342 | if len(out) > 0 { 343 | return out, nil 344 | } 345 | 346 | // This handles the standard case for a SELECT query that successfully 347 | // executes but returns zero rows. 348 | if statementType == "SELECT" { 349 | return "The query returned 0 rows.", nil 350 | } 351 | // This is the fallback for a successful query that doesn't return content. 352 | // In most cases, this will be for DML/DDL statements like INSERT, UPDATE, CREATE, etc. 353 | // However, it is also possible that this was a query that was expected to return rows 354 | // but returned none, a case that we cannot distinguish here. 355 | return "Query executed successfully and returned no content.", nil 356 | } 357 | 358 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 359 | return tools.ParseParams(t.Parameters, data, claims) 360 | } 361 | 362 | func (t Tool) Manifest() tools.Manifest { 363 | return t.manifest 364 | } 365 | 366 | func (t Tool) McpManifest() tools.McpManifest { 367 | return t.mcpManifest 368 | } 369 | 370 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 371 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 372 | } 373 | 374 | func (t Tool) RequiresClientAuthorization() bool { 375 | return t.UseClientOAuth 376 | } 377 | ``` -------------------------------------------------------------------------------- /internal/tools/looker/lookerhealthvacuum/lookerhealthvacuum.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 lookerhealthvacuum 15 | 16 | import ( 17 | "context" 18 | "encoding/json" 19 | "fmt" 20 | "regexp" 21 | "strings" 22 | 23 | yaml "github.com/goccy/go-yaml" 24 | "github.com/googleapis/genai-toolbox/internal/sources" 25 | lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" 26 | "github.com/googleapis/genai-toolbox/internal/tools" 27 | "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" 28 | "github.com/googleapis/genai-toolbox/internal/util" 29 | "github.com/looker-open-source/sdk-codegen/go/rtl" 30 | v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" 31 | ) 32 | 33 | // ================================================================================================================= 34 | // START MCP SERVER CORE LOGIC 35 | // ================================================================================================================= 36 | const kind string = "looker-health-vacuum" 37 | 38 | func init() { 39 | if !tools.Register(kind, newConfig) { 40 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 41 | } 42 | } 43 | 44 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 45 | actual := Config{Name: name} 46 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 47 | return nil, err 48 | } 49 | return actual, nil 50 | } 51 | 52 | type Config struct { 53 | Name string `yaml:"name" validate:"required"` 54 | Kind string `yaml:"kind" validate:"required"` 55 | Source string `yaml:"source" validate:"required"` 56 | Description string `yaml:"description" validate:"required"` 57 | AuthRequired []string `yaml:"authRequired"` 58 | Parameters map[string]any `yaml:"parameters"` 59 | } 60 | 61 | var _ tools.ToolConfig = Config{} 62 | 63 | func (cfg Config) ToolConfigKind() string { 64 | return kind 65 | } 66 | 67 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 68 | rawS, ok := srcs[cfg.Source] 69 | if !ok { 70 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 71 | } 72 | 73 | s, ok := rawS.(*lookersrc.Source) 74 | if !ok { 75 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) 76 | } 77 | 78 | actionParameter := tools.NewStringParameterWithRequired("action", "The vacuum action to run. Can be 'models', or 'explores'.", true) 79 | projectParameter := tools.NewStringParameterWithDefault("project", "", "The Looker project to vacuum (optional).") 80 | modelParameter := tools.NewStringParameterWithDefault("model", "", "The Looker model to vacuum (optional).") 81 | exploreParameter := tools.NewStringParameterWithDefault("explore", "", "The Looker explore to vacuum (optional).") 82 | timeframeParameter := tools.NewIntParameterWithDefault("timeframe", 90, "The timeframe in days to analyze.") 83 | minQueriesParameter := tools.NewIntParameterWithDefault("min_queries", 1, "The minimum number of queries for a model or explore to be considered used.") 84 | 85 | parameters := tools.Parameters{ 86 | actionParameter, 87 | projectParameter, 88 | modelParameter, 89 | exploreParameter, 90 | timeframeParameter, 91 | minQueriesParameter, 92 | } 93 | 94 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) 95 | 96 | return Tool{ 97 | Name: cfg.Name, 98 | Kind: kind, 99 | Parameters: parameters, 100 | AuthRequired: cfg.AuthRequired, 101 | UseClientOAuth: s.UseClientOAuth, 102 | Client: s.Client, 103 | ApiSettings: s.ApiSettings, 104 | manifest: tools.Manifest{ 105 | Description: cfg.Description, 106 | Parameters: parameters.Manifest(), 107 | AuthRequired: cfg.AuthRequired, 108 | }, 109 | mcpManifest: mcpManifest, 110 | }, nil 111 | } 112 | 113 | var _ tools.Tool = Tool{} 114 | 115 | type Tool struct { 116 | Name string `yaml:"name"` 117 | Kind string `yaml:"kind"` 118 | UseClientOAuth bool 119 | Client *v4.LookerSDK 120 | ApiSettings *rtl.ApiSettings 121 | AuthRequired []string `yaml:"authRequired"` 122 | Parameters tools.Parameters 123 | manifest tools.Manifest 124 | mcpManifest tools.McpManifest 125 | } 126 | 127 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 128 | sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken) 129 | if err != nil { 130 | return nil, fmt.Errorf("error getting sdk: %w", err) 131 | } 132 | 133 | paramsMap := params.AsMap() 134 | timeframe, _ := paramsMap["timeframe"].(int) 135 | if timeframe == 0 { 136 | timeframe = 90 137 | } 138 | minQueries, _ := paramsMap["min_queries"].(int) 139 | if minQueries == 0 { 140 | minQueries = 1 141 | } 142 | 143 | vacuumTool := &vacuumTool{ 144 | SdkClient: sdk, 145 | timeframe: timeframe, 146 | minQueries: minQueries, 147 | } 148 | 149 | action, ok := paramsMap["action"].(string) 150 | if !ok { 151 | return nil, fmt.Errorf("action parameter not found") 152 | } 153 | 154 | switch action { 155 | case "models": 156 | project, _ := paramsMap["project"].(string) 157 | model, _ := paramsMap["model"].(string) 158 | return vacuumTool.models(ctx, project, model) 159 | case "explores": 160 | model, _ := paramsMap["model"].(string) 161 | explore, _ := paramsMap["explore"].(string) 162 | return vacuumTool.explores(ctx, model, explore) 163 | default: 164 | return nil, fmt.Errorf("unknown action: %s", action) 165 | } 166 | } 167 | 168 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 169 | return tools.ParseParams(t.Parameters, data, claims) 170 | } 171 | 172 | func (t Tool) Manifest() tools.Manifest { 173 | return t.manifest 174 | } 175 | 176 | func (t Tool) McpManifest() tools.McpManifest { 177 | return t.mcpManifest 178 | } 179 | 180 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 181 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 182 | } 183 | 184 | func (t Tool) RequiresClientAuthorization() bool { 185 | return t.UseClientOAuth 186 | } 187 | 188 | // ================================================================================================================= 189 | // END MCP SERVER CORE LOGIC 190 | // ================================================================================================================= 191 | 192 | // ================================================================================================================= 193 | // START LOOKER HEALTH VACUUM CORE LOGIC 194 | // ================================================================================================================= 195 | type vacuumTool struct { 196 | SdkClient *v4.LookerSDK 197 | timeframe int 198 | minQueries int 199 | } 200 | 201 | func (t *vacuumTool) models(ctx context.Context, project, model string) ([]map[string]interface{}, error) { 202 | logger, err := util.LoggerFromContext(ctx) 203 | if err != nil { 204 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 205 | } 206 | logger.InfoContext(ctx, "Vacuuming models...") 207 | 208 | usedModels, err := t.getUsedModels(ctx) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | lookmlModels, err := t.SdkClient.AllLookmlModels(v4.RequestAllLookmlModels{}, nil) 214 | if err != nil { 215 | return nil, fmt.Errorf("error fetching LookML models: %w", err) 216 | } 217 | 218 | var results []map[string]interface{} 219 | for _, m := range lookmlModels { 220 | if (project == "" || (m.ProjectName != nil && *m.ProjectName == project)) && 221 | (model == "" || (m.Name != nil && *m.Name == model)) { 222 | 223 | queryCount := 0 224 | if qc, ok := usedModels[*m.Name]; ok { 225 | queryCount = qc 226 | } 227 | 228 | unusedExplores, err := t.getUnusedExplores(ctx, *m.Name) 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | results = append(results, map[string]interface{}{ 234 | "Model": *m.Name, 235 | "Unused Explores": unusedExplores, 236 | "Model Query Count": queryCount, 237 | }) 238 | } 239 | } 240 | return results, nil 241 | } 242 | 243 | func (t *vacuumTool) explores(ctx context.Context, model, explore string) ([]map[string]interface{}, error) { 244 | logger, err := util.LoggerFromContext(ctx) 245 | if err != nil { 246 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 247 | } 248 | logger.InfoContext(ctx, "Vacuuming explores...") 249 | 250 | lookmlModels, err := t.SdkClient.AllLookmlModels(v4.RequestAllLookmlModels{}, nil) 251 | if err != nil { 252 | return nil, fmt.Errorf("error fetching LookML models: %w", err) 253 | } 254 | 255 | var results []map[string]interface{} 256 | for _, m := range lookmlModels { 257 | if model != "" && (m.Name == nil || *m.Name != model) { 258 | continue 259 | } 260 | if m.Explores == nil { 261 | continue 262 | } 263 | 264 | for _, e := range *m.Explores { 265 | if explore != "" && (e.Name == nil || *e.Name != explore) { 266 | continue 267 | } 268 | if e.Name == nil { 269 | continue 270 | } 271 | 272 | exploreDetail, err := t.SdkClient.LookmlModelExplore(v4.RequestLookmlModelExplore{ 273 | LookmlModelName: *m.Name, 274 | ExploreName: *e.Name, 275 | }, nil) 276 | if err != nil { 277 | logger.ErrorContext(ctx, fmt.Sprintf("Error fetching detail for explore %s.%s: %v", *m.Name, *e.Name, err)) 278 | continue 279 | } 280 | 281 | usedFields, err := t.getUsedExploreFields(ctx, *m.Name, *e.Name) 282 | if err != nil { 283 | logger.ErrorContext(ctx, fmt.Sprintf("Error fetching used fields for explore %s.%s: %v", *m.Name, *e.Name, err)) 284 | continue 285 | } 286 | 287 | var allFields []string 288 | if exploreDetail.Fields != nil { 289 | for _, d := range *exploreDetail.Fields.Dimensions { 290 | if !*d.Hidden { 291 | allFields = append(allFields, *d.Name) 292 | } 293 | } 294 | for _, ms := range *exploreDetail.Fields.Measures { 295 | if !*ms.Hidden { 296 | allFields = append(allFields, *ms.Name) 297 | } 298 | } 299 | } 300 | 301 | var unusedFields []string 302 | for _, field := range allFields { 303 | if _, ok := usedFields[field]; !ok { 304 | unusedFields = append(unusedFields, field) 305 | } 306 | } 307 | 308 | joinStats := make(map[string]int) 309 | if exploreDetail.Joins != nil { 310 | for field, queryCount := range usedFields { 311 | join := strings.Split(field, ".")[0] 312 | joinStats[join] += queryCount 313 | } 314 | for _, join := range *exploreDetail.Joins { 315 | if _, ok := joinStats[*join.Name]; !ok { 316 | joinStats[*join.Name] = 0 317 | } 318 | } 319 | } 320 | 321 | var unusedJoins []string 322 | for join, count := range joinStats { 323 | if count == 0 { 324 | unusedJoins = append(unusedJoins, join) 325 | } 326 | } 327 | 328 | results = append(results, map[string]interface{}{ 329 | "Model": *m.Name, 330 | "Explore": *e.Name, 331 | "Unused Joins": unusedJoins, 332 | "Unused Fields": unusedFields, 333 | }) 334 | } 335 | } 336 | return results, nil 337 | } 338 | 339 | func (t *vacuumTool) getUsedModels(ctx context.Context) (map[string]int, error) { 340 | limit := "5000" 341 | query := &v4.WriteQuery{ 342 | Model: "system__activity", 343 | View: "history", 344 | Fields: &[]string{"history.query_run_count", "query.model"}, 345 | Filters: &map[string]any{ 346 | "history.created_date": fmt.Sprintf("%d days", t.timeframe), 347 | "query.model": "-system__activity, -i__looker", 348 | "history.query_run_count": fmt.Sprintf(">%d", t.minQueries-1), 349 | "user.dev_branch_name": "NULL", 350 | }, 351 | Limit: &limit, 352 | } 353 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", nil) 354 | if err != nil { 355 | return nil, err 356 | } 357 | 358 | var data []map[string]interface{} 359 | _ = json.Unmarshal([]byte(raw), &data) 360 | 361 | results := make(map[string]int) 362 | for _, row := range data { 363 | model, _ := row["query.model"].(string) 364 | count, _ := row["history.query_run_count"].(float64) 365 | results[model] = int(count) 366 | } 367 | return results, nil 368 | } 369 | 370 | func (t *vacuumTool) getUnusedExplores(ctx context.Context, modelName string) ([]string, error) { 371 | lookmlModel, err := t.SdkClient.LookmlModel(modelName, "", nil) 372 | if err != nil { 373 | return nil, fmt.Errorf("error fetching LookML model %s: %w", modelName, err) 374 | } 375 | 376 | var unusedExplores []string 377 | if lookmlModel.Explores != nil { 378 | for _, e := range *lookmlModel.Explores { 379 | limit := "1" 380 | queryCountQueryBody := &v4.WriteQuery{ 381 | Model: "system__activity", 382 | View: "history", 383 | Fields: &[]string{"history.query_run_count"}, 384 | Filters: &map[string]any{ 385 | "query.model": modelName, 386 | "query.view": *e.Name, 387 | "history.created_date": fmt.Sprintf("%d days", t.timeframe), 388 | "history.query_run_count": fmt.Sprintf(">%d", t.minQueries-1), 389 | "user.dev_branch_name": "NULL", 390 | }, 391 | Limit: &limit, 392 | } 393 | 394 | rawQueryCount, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, queryCountQueryBody, "json", nil) 395 | if err != nil { 396 | // Log the error but continue 397 | continue 398 | } 399 | 400 | var data []map[string]interface{} 401 | _ = json.Unmarshal([]byte(rawQueryCount), &data) 402 | if len(data) == 0 { 403 | unusedExplores = append(unusedExplores, *e.Name) 404 | } 405 | } 406 | } 407 | return unusedExplores, nil 408 | } 409 | 410 | func (t *vacuumTool) getUsedExploreFields(ctx context.Context, model, explore string) (map[string]int, error) { 411 | limit := "5000" 412 | query := &v4.WriteQuery{ 413 | Model: "system__activity", 414 | View: "history", 415 | Fields: &[]string{"query.formatted_fields", "query.filters", "history.query_run_count"}, 416 | Filters: &map[string]any{ 417 | "history.created_date": fmt.Sprintf("%d days", t.timeframe), 418 | "query.model": strings.ReplaceAll(model, "_", "^_"), 419 | "query.view": strings.ReplaceAll(explore, "_", "^_"), 420 | "query.formatted_fields": "-NULL", 421 | "history.workspace_id": "production", 422 | }, 423 | Limit: &limit, 424 | } 425 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", nil) 426 | if err != nil { 427 | return nil, err 428 | } 429 | 430 | var data []map[string]interface{} 431 | _ = json.Unmarshal([]byte(raw), &data) 432 | 433 | results := make(map[string]int) 434 | fieldRegex := regexp.MustCompile(`(\w+\.\w+)`) 435 | 436 | for _, row := range data { 437 | count, _ := row["history.query_run_count"].(float64) 438 | formattedFields, _ := row["query.formatted_fields"].(string) 439 | filters, _ := row["query.filters"].(string) 440 | 441 | usedFields := make(map[string]bool) 442 | 443 | for _, field := range fieldRegex.FindAllString(formattedFields, -1) { 444 | results[field] += int(count) 445 | usedFields[field] = true 446 | } 447 | 448 | for _, field := range fieldRegex.FindAllString(filters, -1) { 449 | if _, ok := usedFields[field]; !ok { 450 | results[field] += int(count) 451 | } 452 | } 453 | } 454 | return results, nil 455 | } 456 | 457 | // ================================================================================================================= 458 | // END LOOKER HEALTH VACUUM CORE LOGIC 459 | // ================================================================================================================= 460 | ``` -------------------------------------------------------------------------------- /internal/server/static/css/style.css: -------------------------------------------------------------------------------- ```css 1 | :root { 2 | --toolbox-blue: #4285f4; 3 | --text-primary-gray: #444444; 4 | --text-secondary-gray: #6e6e6e; 5 | --button-primary: var(--toolbox-blue); 6 | --button-secondary: #616161; 7 | --section-border: #e0e0e0; 8 | } 9 | 10 | body { 11 | display: flex; 12 | height: 100vh; 13 | margin: 0; 14 | font-family: 'Trebuchet MS'; 15 | background-color: #f8f9fa; 16 | box-sizing: border-box; 17 | } 18 | 19 | *, *:before, *:after { 20 | box-sizing: inherit; 21 | } 22 | 23 | #navbar-container { 24 | flex: 0 0 250px; 25 | height: 100%; 26 | position: relative; 27 | z-index: 10; 28 | } 29 | 30 | #main-content-container { 31 | flex: 1; 32 | display: flex; 33 | flex-direction: column; 34 | min-width: 0; 35 | overflow-x: hidden; 36 | } 37 | 38 | .left-nav { 39 | background-color: #fff; 40 | box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15); 41 | display: flex; 42 | flex-direction: column; 43 | padding: 15px; 44 | align-items: center; 45 | width: 100%; 46 | height: 100%; 47 | z-index: 3; 48 | 49 | ul { 50 | font-family: 'Verdana'; 51 | list-style: none; 52 | padding: 0; 53 | margin: 0; 54 | width: 100%; 55 | 56 | li { 57 | margin-bottom: 5px; 58 | 59 | a { 60 | display: flex; 61 | align-items: center; 62 | padding: 12px; 63 | text-decoration: none; 64 | color: #333; 65 | border-radius: 0; 66 | 67 | &:hover { 68 | background-color: #e9e9e9; 69 | border-radius: 35px; 70 | } 71 | 72 | &.active { 73 | background-color: #d0d0d0; 74 | font-weight: bold; 75 | border-radius: 35px; 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | .second-nav { 83 | flex: 0 0 250px; 84 | background-color: #fff; 85 | box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15); 86 | z-index: 2; 87 | display: flex; 88 | flex-direction: column; 89 | padding: 15px; 90 | align-items: center; 91 | position: relative; 92 | } 93 | 94 | .nav-logo { 95 | width: 90%; 96 | margin-bottom: 40px; 97 | flex-shrink: 0; 98 | 99 | img { 100 | max-width: 100%; 101 | height: auto; 102 | display: block; 103 | cursor: pointer; 104 | } 105 | } 106 | 107 | .main-content-area { 108 | flex: 1; 109 | display: flex; 110 | flex-direction: column; 111 | min-width: 0; 112 | overflow-x: hidden; 113 | } 114 | 115 | .top-bar { 116 | background-color: #fff; 117 | padding: 30px 30px; 118 | display: flex; 119 | justify-content: flex-end; 120 | align-items: center; 121 | border-bottom: 1px solid #eee; 122 | } 123 | 124 | .content { 125 | padding: 20px; 126 | flex-grow: 1; 127 | overflow-y: auto; 128 | display: flex; 129 | flex-direction: column; 130 | } 131 | 132 | .btn { 133 | display: flex; 134 | align-items: center; 135 | justify-content: center; 136 | padding: 10px 20px; 137 | color: white; 138 | border: none; 139 | border-radius: 30px; 140 | font: inherit; 141 | font-size: 1em; 142 | font-weight: bolder; 143 | cursor: pointer; 144 | 145 | &:hover { 146 | opacity: 0.8; 147 | } 148 | } 149 | 150 | .btn--run { 151 | background-color: var(--button-primary); 152 | } 153 | 154 | .btn--editHeaders { 155 | background-color: var(--button-secondary) 156 | } 157 | 158 | .btn--saveHeaders { 159 | background-color: var(--button-primary) 160 | } 161 | 162 | .btn--closeHeaders { 163 | background-color: var(--button-secondary) 164 | } 165 | 166 | .btn--setup-gis { 167 | background-color: white; 168 | color: var(--text-primary-gray); 169 | border: 2px solid var(--text-primary-gray); 170 | } 171 | 172 | .btn--externalDocs { 173 | background-color: var(--button-secondary); 174 | text-decoration: none; 175 | display: inline-flex; 176 | } 177 | 178 | .tool-button { 179 | display: flex; 180 | align-items: center; 181 | padding: 12px; 182 | text-decoration: none; 183 | color: #333; 184 | background-color: transparent; 185 | border: none; 186 | border-radius: 0; 187 | width: 100%; 188 | text-align: left; 189 | cursor: pointer; 190 | font-family: inherit; 191 | font-size: inherit; 192 | 193 | transition: background-color 0.1s ease-in-out, border-radius 0.1s ease-in-out; 194 | 195 | &:hover { 196 | background-color: #e9e9e9; 197 | border-radius: 35px; 198 | } 199 | 200 | &:focus { 201 | outline: none; 202 | box-shadow: 0 0 0 2px rgba(208, 208, 208, 0.5); 203 | } 204 | 205 | &.active { 206 | background-color: #d0d0d0; 207 | font-weight: bold; 208 | border-radius: 35px; 209 | 210 | &:hover { 211 | background-color: #d0d0d0; 212 | } 213 | } 214 | } 215 | 216 | #secondary-panel-content { 217 | flex: 1; 218 | overflow-y: auto; 219 | width: 100%; 220 | min-height: 0; 221 | 222 | ul { 223 | list-style: none; 224 | padding: 0; 225 | margin: 0; 226 | width: 100%; 227 | } 228 | } 229 | 230 | .tool-details-grid { 231 | display: grid; 232 | grid-template-columns: 1fr 2fr; 233 | gap: 20px; 234 | margin: 0 0 20px 0; 235 | align-items: start; 236 | flex-shrink: 0; 237 | } 238 | 239 | .tool-info { 240 | display: flex; 241 | flex-direction: column; 242 | gap: 15px; 243 | } 244 | 245 | .tool-execution-area { 246 | display: flex; 247 | flex-direction: column; 248 | gap: 12px; 249 | } 250 | 251 | .tool-params { 252 | background-color: #ffffff; 253 | padding: 15px; 254 | border-radius: 4px; 255 | border: 1px solid #ddd; 256 | 257 | h5 { 258 | margin-bottom: 0; 259 | } 260 | } 261 | 262 | .tool-box { 263 | background-color: #ffffff; 264 | padding: 15px; 265 | border-radius: 4px; 266 | border: 1px solid #eee; 267 | 268 | h5 { 269 | color: var(--toolbox-blue); 270 | margin-top: 0; 271 | font-weight: bold; 272 | } 273 | } 274 | 275 | .params-header { 276 | display: flex; 277 | justify-content: flex-end; 278 | margin-bottom: 8px; 279 | padding-right: 6px; 280 | font-weight: bold; 281 | font-size: 0.9em; 282 | color: var(--text-secondary-gray); 283 | } 284 | 285 | .params-disclaimer { 286 | font-style: italic; 287 | color: var(--text-secondary-gray); 288 | font-size: 0.8em; 289 | margin-bottom: 10px; 290 | width: 100%; 291 | word-wrap: break-word; 292 | } 293 | 294 | .param-item { 295 | margin-bottom: 12px; 296 | 297 | label { 298 | display: block; 299 | margin-bottom: 4px; 300 | font-family: inherit; 301 | } 302 | 303 | &.disabled-param { 304 | > label { 305 | color: #888; 306 | text-decoration: line-through; 307 | } 308 | 309 | .param-input-element { 310 | background-color: #f5f5f5; 311 | opacity: 0.6; 312 | } 313 | } 314 | 315 | input[type="text"], 316 | input[type="number"], 317 | select, 318 | textarea { 319 | width: calc(100% - 12px); 320 | padding: 6px; 321 | border: 1px solid #ccc; 322 | border-radius: 4px; 323 | font-family: inherit; 324 | } 325 | 326 | input[type="checkbox"].param-input-element { 327 | width: auto; 328 | padding: 0; 329 | border: initial; 330 | border-radius: initial; 331 | vertical-align: middle; 332 | margin-right: 4px; 333 | accent-color: var(--toolbox-blue); 334 | flex-grow: 0; 335 | } 336 | } 337 | 338 | .input-checkbox-wrapper { 339 | display: flex; 340 | align-items: center; 341 | gap: 10px; 342 | } 343 | 344 | .param-input-element-container { 345 | flex-grow: 1; 346 | } 347 | 348 | .param-input-element { 349 | box-sizing: border-box; 350 | } 351 | 352 | .include-param-container { 353 | display: flex; 354 | align-items: center; 355 | white-space: nowrap; 356 | 357 | input[type="checkbox"] { 358 | width: auto; 359 | padding: 0; 360 | border: initial; 361 | border-radius: initial; 362 | vertical-align: middle; 363 | margin-right: 0; 364 | accent-color: var(--toolbox-blue); 365 | } 366 | } 367 | 368 | .include-param-container input[type="checkbox"] { 369 | width: auto; 370 | padding: 0; 371 | border: initial; 372 | border-radius: initial; 373 | vertical-align: middle; 374 | margin: 0; 375 | accent-color: var(--toolbox-blue); 376 | } 377 | 378 | .checkbox-bool-label { 379 | margin-left: 5px; 380 | font-style: italic; 381 | color: var(--text-primary-gray); 382 | } 383 | 384 | .checkbox-bool-label.disabled { 385 | color: #aaa; 386 | cursor: not-allowed; 387 | } 388 | 389 | .param-label-extras { 390 | font-style: italic; 391 | font-weight: lighter; 392 | color: var(--text-secondary-gray); 393 | } 394 | 395 | .auth-param-input { 396 | background-color: var(--section-border); 397 | cursor: not-allowed; 398 | } 399 | 400 | .run-button-container { 401 | display: flex; 402 | justify-content: flex-end; 403 | gap: 20px; 404 | } 405 | 406 | .header-modal { 407 | display: none; 408 | position: fixed; 409 | z-index: 1000; 410 | left: 0; 411 | top: 0; 412 | width: 100%; 413 | height: 100%; 414 | overflow: auto; 415 | background-color: rgba(0,0,0,0.4); 416 | 417 | li { 418 | margin-bottom: 10px; 419 | } 420 | 421 | .header-modal-content { 422 | background-color: #fefefe; 423 | margin: 10% auto; 424 | padding: 20px; 425 | border: 1px solid #888; 426 | width: 80%; 427 | max-width: 50%; 428 | border-radius: 8px; 429 | display: flex; 430 | flex-direction: column; 431 | gap: 15px; 432 | align-items: center; 433 | 434 | h5 { 435 | margin-top: 0; 436 | font-size: 1.2em; 437 | } 438 | 439 | .headers-textarea { 440 | width: calc(100% - 16px); 441 | padding: 8px; 442 | font-family: monospace; 443 | border: 1px solid #ccc; 444 | border-radius: 4px; 445 | min-height: 150px; 446 | } 447 | 448 | .header-modal-actions { 449 | display: flex; 450 | justify-content: center; 451 | gap: 30px; 452 | width: 100%; 453 | } 454 | 455 | .auth-token-details { 456 | width: 100%; 457 | max-width: calc(100% - 16px); 458 | margin-left: 8px; 459 | margin-right: 8px; 460 | 461 | summary { 462 | cursor: pointer; 463 | text-align: left; 464 | padding: 5px 0; 465 | } 466 | 467 | .auth-token-content { 468 | padding: 10px; 469 | border: 1px solid #eee; 470 | margin-top: 5px; 471 | background-color: #f9f9f9; 472 | text-align: left; 473 | max-width: 100%; 474 | overflow-wrap: break-word; 475 | 476 | .auth-tab-group { 477 | display: flex; 478 | border-bottom: 1px solid #ccc; 479 | margin-bottom: 10px; 480 | } 481 | 482 | .auth-tab-picker { 483 | padding: 8px 12px; 484 | cursor: pointer; 485 | border: 1px solid transparent; 486 | border-bottom: 1px solid transparent; 487 | margin-bottom: -1px; 488 | background-color: #f0f0f0; 489 | 490 | &.active { 491 | background-color: #fff; 492 | border-color: #ccc; 493 | border-bottom-color: #fff; 494 | font-weight: bold; 495 | } 496 | } 497 | 498 | .auth-tab-content { 499 | display: none; 500 | overflow-wrap: break-word; 501 | word-wrap: break-word; 502 | max-width: 100%; 503 | 504 | &.active { 505 | display: block; 506 | } 507 | 508 | pre { 509 | white-space: pre-wrap; 510 | word-wrap: break-word; 511 | overflow-x: auto; 512 | background-color: #f5f5f5; 513 | padding: 10px; 514 | border: 1px solid #ccc; 515 | border-radius: 4px; 516 | max-width: 100%; 517 | 518 | code { 519 | display: block; 520 | word-wrap: break-word; 521 | color: inherit; 522 | } 523 | } 524 | } 525 | } 526 | } 527 | } 528 | } 529 | 530 | .auth-method-header { 531 | display: flex; 532 | justify-content: space-between; 533 | align-items: center; 534 | padding: 8px 12px; 535 | } 536 | 537 | .auth-method-label { 538 | font-weight: 500; 539 | color: var(--text-primary-gray); 540 | word-break: break-word; 541 | } 542 | 543 | .auth-helper-section { 544 | border: 1px solid var(--section-border); 545 | background-color: transparent; 546 | padding: 16px; 547 | border-radius: 8px; 548 | margin-top: 20px; 549 | width: 80%; 550 | } 551 | 552 | .auth-method-list { 553 | display: flex; 554 | flex-direction: column; 555 | gap: 12px; 556 | } 557 | 558 | .auth-method-details { 559 | padding: 16px; 560 | border: 1px solid var(--section-border); 561 | border-radius: 4px; 562 | margin-bottom: 16px; 563 | background-color: #fff; 564 | overflow-x: auto; 565 | } 566 | 567 | .auth-controls { 568 | display: flex; 569 | flex-direction: column; 570 | gap: 16px; 571 | align-items: flex-start; 572 | } 573 | 574 | .auth-input-row { 575 | display: flex; 576 | flex-direction: column; 577 | gap: 6px; 578 | 579 | & label { 580 | font-size: 14px; 581 | color: var(--text-primary-gray); 582 | margin-bottom: 4px; 583 | } 584 | } 585 | 586 | .auth-input { 587 | padding: 8px 8px; 588 | border: 1px solid #bdc1c6; 589 | border-radius: 4px; 590 | font-size: 14px; 591 | width: 100%; 592 | box-sizing: border-box; 593 | 594 | &:focus { 595 | outline: none; 596 | border-color: var(--toolbox-blue); 597 | box-shadow: 0 0 0 1px #1a73e8; 598 | } 599 | } 600 | 601 | .auth-method-actions { 602 | display: flex; 603 | align-items: center; 604 | gap: 12px; 605 | } 606 | 607 | .auth-instructions { 608 | font-size: 0.8em; 609 | margin-top: 5px; 610 | color: var(--text-secondary-gray); 611 | } 612 | 613 | .tool-response { 614 | margin: 20px 0 0 0; 615 | 616 | textarea { 617 | width: 100%; 618 | min-height: 150px; 619 | padding: 12px; 620 | border: 1px solid #ddd; 621 | border-radius: 4px; 622 | font-family: monospace; 623 | } 624 | } 625 | 626 | .search-container { 627 | display: flex; 628 | width: 100%; 629 | margin-bottom: 15px; 630 | 631 | #toolset-search-input { 632 | flex-grow: 1; 633 | padding: 10px 12px; 634 | border: 1px solid #ccc; 635 | border-radius: 20px 0 0 20px; 636 | border-right: none; 637 | font-family: inherit; 638 | font-size: 0.9em; 639 | color: var(--text-primary-gray); 640 | 641 | &:focus { 642 | outline: none; 643 | border-color: var(--toolbox-blue); 644 | box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3); 645 | } 646 | 647 | &::placeholder { 648 | color: var(--text-secondary-gray); 649 | } 650 | } 651 | 652 | #toolset-search-button { 653 | padding: 10px 15px; 654 | border: 1px solid var(--button-primary); 655 | background-color: var(--button-primary); 656 | color: white; 657 | border-radius: 0 20px 20px 0; 658 | cursor: pointer; 659 | font-family: inherit; 660 | font-size: 0.9em; 661 | font-weight: bold; 662 | transition: opacity 0.2s ease-in-out; 663 | flex-shrink: 0; 664 | line-height: 1; 665 | 666 | &:hover { 667 | opacity: 0.8; 668 | } 669 | 670 | &:focus { 671 | outline: none; 672 | box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3); 673 | } 674 | } 675 | } 676 | 677 | .toggle-details-tab { 678 | background-color: transparent; 679 | color: var(--toolbox-blue); 680 | border: none; 681 | padding: 8px 12px; 682 | border-radius: 4px; 683 | cursor: pointer; 684 | font-size: 1em; 685 | font-weight: bold; 686 | 687 | &:hover { 688 | opacity: 0.8; 689 | } 690 | } 691 | 692 | .resource-instructions { 693 | font-family: inherit; 694 | color: var(--text-primary-gray); 695 | padding: 24px; 696 | background-color: #ffffff; 697 | border-radius: 4px; 698 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 699 | margin: 16px 0; 700 | 701 | li { 702 | margin-bottom: 10px; 703 | color: var(--text-secondary-gray); 704 | } 705 | } 706 | 707 | .resource-title { 708 | color: var(--toolbox-blue); 709 | font-size: 24px; 710 | font-weight: bold; 711 | margin-top: 0; 712 | margin-bottom: 16px; 713 | } 714 | 715 | .resource-subtitle { 716 | color: var(--text-primary-gray); 717 | font-size: 20px; 718 | font-weight: normal; 719 | margin-top: 24px; 720 | margin-bottom: 12px; 721 | } 722 | 723 | .resource-intro, 724 | .resource-description { 725 | font-size: 16px; 726 | line-height: 1.6; 727 | color: var(--text-secondary-gray); 728 | margin-bottom: 16px; 729 | 730 | code { 731 | background-color: #e9e9e9; 732 | color: #c0392b; 733 | padding: 2px 4px; 734 | border-radius: 4px; 735 | font-family: monospace; 736 | font-size: 90%; 737 | vertical-align: baseline; 738 | } 739 | } 740 | ``` -------------------------------------------------------------------------------- /internal/tools/neo4j/neo4jschema/helpers/helpers_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 helpers 16 | 17 | import ( 18 | "testing" 19 | "time" 20 | 21 | "github.com/google/go-cmp/cmp" 22 | "github.com/googleapis/genai-toolbox/internal/tools/neo4j/neo4jschema/types" 23 | "github.com/neo4j/neo4j-go-driver/v5/neo4j" 24 | ) 25 | 26 | func TestHelperFunctions(t *testing.T) { 27 | t.Run("ConvertToStringSlice", func(t *testing.T) { 28 | tests := []struct { 29 | name string 30 | input []any 31 | want []string 32 | }{ 33 | { 34 | name: "empty slice", 35 | input: []any{}, 36 | want: []string{}, 37 | }, 38 | { 39 | name: "string values", 40 | input: []any{"a", "b", "c"}, 41 | want: []string{"a", "b", "c"}, 42 | }, 43 | { 44 | name: "mixed types", 45 | input: []any{"string", 123, true, 45.67}, 46 | want: []string{"string", "123", "true", "45.67"}, 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | got := ConvertToStringSlice(tt.input) 53 | if diff := cmp.Diff(tt.want, got); diff != "" { 54 | t.Errorf("ConvertToStringSlice() mismatch (-want +got):\n%s", diff) 55 | } 56 | }) 57 | } 58 | }) 59 | 60 | t.Run("GetStringValue", func(t *testing.T) { 61 | tests := []struct { 62 | name string 63 | input any 64 | want string 65 | }{ 66 | { 67 | name: "nil value", 68 | input: nil, 69 | want: "", 70 | }, 71 | { 72 | name: "string value", 73 | input: "test", 74 | want: "test", 75 | }, 76 | { 77 | name: "int value", 78 | input: 42, 79 | want: "42", 80 | }, 81 | { 82 | name: "bool value", 83 | input: true, 84 | want: "true", 85 | }, 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | got := GetStringValue(tt.input) 91 | if got != tt.want { 92 | t.Errorf("GetStringValue() got %q, want %q", got, tt.want) 93 | } 94 | }) 95 | } 96 | }) 97 | } 98 | 99 | func TestMapToAPOCSchema(t *testing.T) { 100 | tests := []struct { 101 | name string 102 | input map[string]any 103 | want *types.APOCSchemaResult 104 | wantErr bool 105 | }{ 106 | { 107 | name: "simple node schema", 108 | input: map[string]any{ 109 | "Person": map[string]any{ 110 | "type": "node", 111 | "count": int64(150), 112 | "properties": map[string]any{ 113 | "name": map[string]any{ 114 | "type": "STRING", 115 | "unique": false, 116 | "indexed": true, 117 | "existence": false, 118 | }, 119 | }, 120 | }, 121 | }, 122 | want: &types.APOCSchemaResult{ 123 | Value: map[string]types.APOCEntity{ 124 | "Person": { 125 | Type: "node", 126 | Count: 150, 127 | Properties: map[string]types.APOCProperty{ 128 | "name": { 129 | Type: "STRING", 130 | Unique: false, 131 | Indexed: true, 132 | Existence: false, 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | wantErr: false, 139 | }, 140 | { 141 | name: "empty input", 142 | input: map[string]any{}, 143 | want: &types.APOCSchemaResult{Value: map[string]types.APOCEntity{}}, 144 | wantErr: false, 145 | }, 146 | } 147 | 148 | for _, tt := range tests { 149 | t.Run(tt.name, func(t *testing.T) { 150 | got, err := MapToAPOCSchema(tt.input) 151 | if (err != nil) != tt.wantErr { 152 | t.Errorf("MapToAPOCSchema() error = %v, wantErr %v", err, tt.wantErr) 153 | return 154 | } 155 | if diff := cmp.Diff(tt.want, got); diff != "" { 156 | t.Errorf("MapToAPOCSchema() mismatch (-want +got):\n%s", diff) 157 | } 158 | }) 159 | } 160 | } 161 | 162 | func TestProcessAPOCSchema(t *testing.T) { 163 | tests := []struct { 164 | name string 165 | input *types.APOCSchemaResult 166 | wantNodes []types.NodeLabel 167 | wantRels []types.Relationship 168 | wantStats *types.Statistics 169 | statsAreEmpty bool 170 | }{ 171 | { 172 | name: "empty schema", 173 | input: &types.APOCSchemaResult{ 174 | Value: map[string]types.APOCEntity{}, 175 | }, 176 | wantNodes: nil, 177 | wantRels: nil, 178 | statsAreEmpty: true, 179 | }, 180 | { 181 | name: "simple node only", 182 | input: &types.APOCSchemaResult{ 183 | Value: map[string]types.APOCEntity{ 184 | "Person": { 185 | Type: "node", 186 | Count: 100, 187 | Properties: map[string]types.APOCProperty{ 188 | "name": {Type: "STRING", Indexed: true}, 189 | "age": {Type: "INTEGER"}, 190 | }, 191 | }, 192 | }, 193 | }, 194 | wantNodes: []types.NodeLabel{ 195 | { 196 | Name: "Person", 197 | Count: 100, 198 | Properties: []types.PropertyInfo{ 199 | {Name: "age", Types: []string{"INTEGER"}}, 200 | {Name: "name", Types: []string{"STRING"}, Indexed: true}, 201 | }, 202 | }, 203 | }, 204 | wantRels: nil, 205 | wantStats: &types.Statistics{ 206 | NodesByLabel: map[string]int64{"Person": 100}, 207 | PropertiesByLabel: map[string]int64{"Person": 2}, 208 | TotalNodes: 100, 209 | TotalProperties: 200, 210 | }, 211 | }, 212 | { 213 | name: "nodes and relationships", 214 | input: &types.APOCSchemaResult{ 215 | Value: map[string]types.APOCEntity{ 216 | "Person": { 217 | Type: "node", 218 | Count: 100, 219 | Properties: map[string]types.APOCProperty{ 220 | "name": {Type: "STRING", Unique: true, Indexed: true, Existence: true}, 221 | }, 222 | Relationships: map[string]types.APOCRelationshipInfo{ 223 | "KNOWS": { 224 | Direction: "out", 225 | Count: 50, 226 | Labels: []string{"Person"}, 227 | Properties: map[string]types.APOCProperty{ 228 | "since": {Type: "INTEGER"}, 229 | }, 230 | }, 231 | }, 232 | }, 233 | "Post": { 234 | Type: "node", 235 | Count: 200, 236 | Properties: map[string]types.APOCProperty{"content": {Type: "STRING"}}, 237 | }, 238 | "FOLLOWS": {Type: "relationship", Count: 80}, 239 | }, 240 | }, 241 | wantNodes: []types.NodeLabel{ 242 | { 243 | Name: "Post", 244 | Count: 200, 245 | Properties: []types.PropertyInfo{ 246 | {Name: "content", Types: []string{"STRING"}}, 247 | }, 248 | }, 249 | { 250 | Name: "Person", 251 | Count: 100, 252 | Properties: []types.PropertyInfo{ 253 | {Name: "name", Types: []string{"STRING"}, Unique: true, Indexed: true, Mandatory: true}, 254 | }, 255 | }, 256 | }, 257 | wantRels: []types.Relationship{ 258 | { 259 | Type: "KNOWS", 260 | StartNode: "Person", 261 | EndNode: "Person", 262 | Count: 50, 263 | Properties: []types.PropertyInfo{ 264 | {Name: "since", Types: []string{"INTEGER"}}, 265 | }, 266 | }, 267 | }, 268 | wantStats: &types.Statistics{ 269 | NodesByLabel: map[string]int64{"Person": 100, "Post": 200}, 270 | RelationshipsByType: map[string]int64{"KNOWS": 50}, 271 | PropertiesByLabel: map[string]int64{"Person": 1, "Post": 1}, 272 | PropertiesByRelType: map[string]int64{"KNOWS": 1}, 273 | TotalNodes: 300, 274 | TotalRelationships: 50, 275 | TotalProperties: 350, // (100*1 + 200*1) for nodes + (50*1) for rels 276 | }, 277 | }, 278 | } 279 | 280 | for _, tt := range tests { 281 | t.Run(tt.name, func(t *testing.T) { 282 | gotNodes, gotRels, gotStats := ProcessAPOCSchema(tt.input) 283 | 284 | if diff := cmp.Diff(tt.wantNodes, gotNodes); diff != "" { 285 | t.Errorf("ProcessAPOCSchema() node labels mismatch (-want +got):\n%s", diff) 286 | } 287 | if diff := cmp.Diff(tt.wantRels, gotRels); diff != "" { 288 | t.Errorf("ProcessAPOCSchema() relationships mismatch (-want +got):\n%s", diff) 289 | } 290 | if tt.statsAreEmpty { 291 | tt.wantStats = &types.Statistics{} 292 | } 293 | 294 | if diff := cmp.Diff(tt.wantStats, gotStats); diff != "" { 295 | t.Errorf("ProcessAPOCSchema() statistics mismatch (-want +got):\n%s", diff) 296 | } 297 | }) 298 | } 299 | } 300 | 301 | func TestProcessNonAPOCSchema(t *testing.T) { 302 | t.Run("full schema processing", func(t *testing.T) { 303 | nodeCounts := map[string]int64{"Person": 10, "City": 5} 304 | nodePropsMap := map[string]map[string]map[string]bool{ 305 | "Person": {"name": {"STRING": true}, "age": {"INTEGER": true}}, 306 | "City": {"name": {"STRING": true, "TEXT": true}}, 307 | } 308 | relCounts := map[string]int64{"LIVES_IN": 8} 309 | relPropsMap := map[string]map[string]map[string]bool{ 310 | "LIVES_IN": {"since": {"DATE": true}}, 311 | } 312 | relConnectivity := map[string]types.RelConnectivityInfo{ 313 | "LIVES_IN": {StartNode: "Person", EndNode: "City", Count: 8}, 314 | } 315 | 316 | wantNodes := []types.NodeLabel{ 317 | { 318 | Name: "Person", 319 | Count: 10, 320 | Properties: []types.PropertyInfo{ 321 | {Name: "age", Types: []string{"INTEGER"}}, 322 | {Name: "name", Types: []string{"STRING"}}, 323 | }, 324 | }, 325 | { 326 | Name: "City", 327 | Count: 5, 328 | Properties: []types.PropertyInfo{ 329 | {Name: "name", Types: []string{"STRING", "TEXT"}}, 330 | }, 331 | }, 332 | } 333 | wantRels := []types.Relationship{ 334 | { 335 | Type: "LIVES_IN", 336 | Count: 8, 337 | StartNode: "Person", 338 | EndNode: "City", 339 | Properties: []types.PropertyInfo{ 340 | {Name: "since", Types: []string{"DATE"}}, 341 | }, 342 | }, 343 | } 344 | wantStats := &types.Statistics{ 345 | TotalNodes: 15, 346 | TotalRelationships: 8, 347 | TotalProperties: 33, // (10*2 + 5*1) for nodes + (8*1) for rels 348 | NodesByLabel: map[string]int64{"Person": 10, "City": 5}, 349 | RelationshipsByType: map[string]int64{"LIVES_IN": 8}, 350 | PropertiesByLabel: map[string]int64{"Person": 2, "City": 1}, 351 | PropertiesByRelType: map[string]int64{"LIVES_IN": 1}, 352 | } 353 | 354 | gotNodes, gotRels, gotStats := ProcessNonAPOCSchema(nodeCounts, nodePropsMap, relCounts, relPropsMap, relConnectivity) 355 | 356 | if diff := cmp.Diff(wantNodes, gotNodes); diff != "" { 357 | t.Errorf("ProcessNonAPOCSchema() nodes mismatch (-want +got):\n%s", diff) 358 | } 359 | if diff := cmp.Diff(wantRels, gotRels); diff != "" { 360 | t.Errorf("ProcessNonAPOCSchema() relationships mismatch (-want +got):\n%s", diff) 361 | } 362 | if diff := cmp.Diff(wantStats, gotStats); diff != "" { 363 | t.Errorf("ProcessNonAPOCSchema() stats mismatch (-want +got):\n%s", diff) 364 | } 365 | }) 366 | 367 | t.Run("empty schema", func(t *testing.T) { 368 | gotNodes, gotRels, gotStats := ProcessNonAPOCSchema( 369 | map[string]int64{}, 370 | map[string]map[string]map[string]bool{}, 371 | map[string]int64{}, 372 | map[string]map[string]map[string]bool{}, 373 | map[string]types.RelConnectivityInfo{}, 374 | ) 375 | 376 | if len(gotNodes) != 0 { 377 | t.Errorf("expected 0 nodes, got %d", len(gotNodes)) 378 | } 379 | if len(gotRels) != 0 { 380 | t.Errorf("expected 0 relationships, got %d", len(gotRels)) 381 | } 382 | if diff := cmp.Diff(&types.Statistics{}, gotStats); diff != "" { 383 | t.Errorf("ProcessNonAPOCSchema() stats mismatch (-want +got):\n%s", diff) 384 | } 385 | }) 386 | } 387 | 388 | func TestConvertValue(t *testing.T) { 389 | tests := []struct { 390 | name string 391 | input any 392 | want any 393 | }{ 394 | { 395 | name: "nil value", 396 | input: nil, 397 | want: nil, 398 | }, 399 | { 400 | name: "neo4j.InvalidValue", 401 | input: neo4j.InvalidValue{}, 402 | want: nil, 403 | }, 404 | { 405 | name: "primitive bool", 406 | input: true, 407 | want: true, 408 | }, 409 | { 410 | name: "primitive int", 411 | input: int64(42), 412 | want: int64(42), 413 | }, 414 | { 415 | name: "primitive float", 416 | input: 3.14, 417 | want: 3.14, 418 | }, 419 | { 420 | name: "primitive string", 421 | input: "hello", 422 | want: "hello", 423 | }, 424 | { 425 | name: "neo4j.Date", 426 | input: neo4j.Date(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)), 427 | want: "2024-06-01", 428 | }, 429 | { 430 | name: "neo4j.LocalTime", 431 | input: neo4j.LocalTime(time.Date(0, 0, 0, 12, 34, 56, 0, time.Local)), 432 | want: "12:34:56", 433 | }, 434 | { 435 | name: "neo4j.Time", 436 | input: neo4j.Time(time.Date(0, 0, 0, 1, 2, 3, 0, time.UTC)), 437 | want: "01:02:03Z", 438 | }, 439 | { 440 | name: "neo4j.LocalDateTime", 441 | input: neo4j.LocalDateTime(time.Date(2024, 6, 1, 10, 20, 30, 0, time.Local)), 442 | want: "2024-06-01T10:20:30", 443 | }, 444 | { 445 | name: "neo4j.Duration", 446 | input: neo4j.Duration{Months: 1, Days: 2, Seconds: 3, Nanos: 4}, 447 | want: "P1M2DT3.000000004S", 448 | }, 449 | { 450 | name: "neo4j.Point2D", 451 | input: neo4j.Point2D{X: 1.1, Y: 2.2, SpatialRefId: 1234}, 452 | want: map[string]any{"x": 1.1, "y": 2.2, "srid": uint32(1234)}, 453 | }, 454 | { 455 | name: "neo4j.Point3D", 456 | input: neo4j.Point3D{X: 1.1, Y: 2.2, Z: 3.3, SpatialRefId: 5467}, 457 | want: map[string]any{"x": 1.1, "y": 2.2, "z": 3.3, "srid": uint32(5467)}, 458 | }, 459 | { 460 | name: "neo4j.Node (handled by Entity case, losing labels)", 461 | input: neo4j.Node{ 462 | ElementId: "element-1", 463 | Labels: []string{"Person"}, 464 | Props: map[string]any{"name": "Alice"}, 465 | }, 466 | want: map[string]any{ 467 | "elementId": "element-1", 468 | "labels": []string{"Person"}, 469 | "properties": map[string]any{"name": "Alice"}, 470 | }, 471 | }, 472 | { 473 | name: "neo4j.Relationship (handled by Entity case, losing type/endpoints)", 474 | input: neo4j.Relationship{ 475 | ElementId: "element-2", 476 | StartElementId: "start-1", 477 | EndElementId: "end-1", 478 | Type: "KNOWS", 479 | Props: map[string]any{"since": 2024}, 480 | }, 481 | want: map[string]any{ 482 | "elementId": "element-2", 483 | "properties": map[string]any{"since": 2024}, 484 | "startElementId": "start-1", 485 | "endElementId": "end-1", 486 | "type": "KNOWS", 487 | }, 488 | }, 489 | { 490 | name: "neo4j.Path (elements handled by Entity case)", 491 | input: func() neo4j.Path { 492 | node1 := neo4j.Node{ElementId: "n10", Labels: []string{"A"}, Props: map[string]any{"p1": "v1"}} 493 | node2 := neo4j.Node{ElementId: "n11", Labels: []string{"B"}, Props: map[string]any{"p2": "v2"}} 494 | rel1 := neo4j.Relationship{ElementId: "r12", StartElementId: "n10", EndElementId: "n11", Type: "REL", Props: map[string]any{"p3": "v3"}} 495 | return neo4j.Path{ 496 | Nodes: []neo4j.Node{node1, node2}, 497 | Relationships: []neo4j.Relationship{rel1}, 498 | } 499 | }(), 500 | want: map[string]any{ 501 | "nodes": []any{ 502 | map[string]any{ 503 | "elementId": "n10", 504 | "properties": map[string]any{"p1": "v1"}, 505 | "labels": []string{"A"}, 506 | }, 507 | map[string]any{ 508 | "elementId": "n11", 509 | "properties": map[string]any{"p2": "v2"}, 510 | "labels": []string{"B"}, 511 | }, 512 | }, 513 | "relationships": []any{ 514 | map[string]any{ 515 | "elementId": "r12", 516 | "properties": map[string]any{"p3": "v3"}, 517 | "startElementId": "n10", 518 | "endElementId": "n11", 519 | "type": "REL", 520 | }, 521 | }, 522 | }, 523 | }, 524 | { 525 | name: "slice of primitives", 526 | input: []any{"a", 1, true}, 527 | want: []any{"a", 1, true}, 528 | }, 529 | { 530 | name: "slice of mixed types", 531 | input: []any{"a", neo4j.Date(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC))}, 532 | want: []any{"a", "2024-06-01"}, 533 | }, 534 | { 535 | name: "map of primitives", 536 | input: map[string]any{"foo": 1, "bar": "baz"}, 537 | want: map[string]any{"foo": 1, "bar": "baz"}, 538 | }, 539 | { 540 | name: "map with nested neo4j type", 541 | input: map[string]any{"date": neo4j.Date(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC))}, 542 | want: map[string]any{"date": "2024-06-01"}, 543 | }, 544 | { 545 | name: "unhandled type", 546 | input: struct{ X int }{X: 5}, 547 | want: "{5}", 548 | }, 549 | } 550 | 551 | for _, tt := range tests { 552 | t.Run(tt.name, func(t *testing.T) { 553 | got := ConvertValue(tt.input) 554 | if !cmp.Equal(got, tt.want) { 555 | t.Errorf("ConvertValue() mismatch (-want +got):\n%s", cmp.Diff(tt.want, got)) 556 | } 557 | }) 558 | } 559 | } 560 | ``` -------------------------------------------------------------------------------- /internal/tools/mysql/mysqllisttables/mysqllisttables.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 mysqllisttables 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 | ) 29 | 30 | const kind string = "mysql-list-tables" 31 | 32 | const listTablesStatement = ` 33 | SELECT 34 | T.TABLE_SCHEMA AS schema_name, 35 | T.TABLE_NAME AS object_name, 36 | CASE 37 | WHEN @output_format = 'simple' THEN 38 | JSON_OBJECT('name', T.TABLE_NAME) 39 | ELSE 40 | CONVERT( 41 | JSON_OBJECT( 42 | 'schema_name', T.TABLE_SCHEMA, 43 | 'object_name', T.TABLE_NAME, 44 | 'object_type', 'TABLE', 45 | 'owner', ( 46 | SELECT 47 | IFNULL(U.GRANTEE, 'N/A') 48 | FROM 49 | INFORMATION_SCHEMA.SCHEMA_PRIVILEGES U 50 | WHERE 51 | U.TABLE_SCHEMA = T.TABLE_SCHEMA 52 | LIMIT 1 53 | ), 54 | 'comment', IFNULL(T.TABLE_COMMENT, ''), 55 | 'columns', ( 56 | SELECT 57 | IFNULL( 58 | JSON_ARRAYAGG( 59 | JSON_OBJECT( 60 | 'column_name', C.COLUMN_NAME, 61 | 'data_type', C.COLUMN_TYPE, 62 | 'ordinal_position', C.ORDINAL_POSITION, 63 | 'is_not_nullable', IF(C.IS_NULLABLE = 'NO', TRUE, FALSE), 64 | 'column_default', C.COLUMN_DEFAULT, 65 | 'column_comment', IFNULL(C.COLUMN_COMMENT, '') 66 | ) 67 | ), 68 | JSON_ARRAY() 69 | ) 70 | FROM 71 | INFORMATION_SCHEMA.COLUMNS C 72 | WHERE 73 | C.TABLE_SCHEMA = T.TABLE_SCHEMA AND C.TABLE_NAME = T.TABLE_NAME 74 | ORDER BY C.ORDINAL_POSITION 75 | ), 76 | 'constraints', ( 77 | SELECT 78 | IFNULL( 79 | JSON_ARRAYAGG( 80 | JSON_OBJECT( 81 | 'constraint_name', TC.CONSTRAINT_NAME, 82 | 'constraint_type', 83 | CASE TC.CONSTRAINT_TYPE 84 | WHEN 'PRIMARY KEY' THEN 'PRIMARY KEY' 85 | WHEN 'FOREIGN KEY' THEN 'FOREIGN KEY' 86 | WHEN 'UNIQUE' THEN 'UNIQUE' 87 | ELSE TC.CONSTRAINT_TYPE 88 | END, 89 | 'constraint_definition', '', 90 | 'constraint_columns', ( 91 | SELECT 92 | IFNULL(JSON_ARRAYAGG(KCU.COLUMN_NAME), JSON_ARRAY()) 93 | FROM 94 | INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU 95 | WHERE 96 | KCU.CONSTRAINT_SCHEMA = TC.CONSTRAINT_SCHEMA 97 | AND KCU.CONSTRAINT_NAME = TC.CONSTRAINT_NAME 98 | AND KCU.TABLE_NAME = TC.TABLE_NAME 99 | ORDER BY KCU.ORDINAL_POSITION 100 | ), 101 | 'foreign_key_referenced_table', IF(TC.CONSTRAINT_TYPE = 'FOREIGN KEY', RC.REFERENCED_TABLE_NAME, NULL), 102 | 'foreign_key_referenced_columns', IF(TC.CONSTRAINT_TYPE = 'FOREIGN KEY', 103 | (SELECT IFNULL(JSON_ARRAYAGG(FKCU.REFERENCED_COLUMN_NAME), JSON_ARRAY()) 104 | FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE FKCU 105 | WHERE FKCU.CONSTRAINT_SCHEMA = TC.CONSTRAINT_SCHEMA 106 | AND FKCU.CONSTRAINT_NAME = TC.CONSTRAINT_NAME 107 | AND FKCU.TABLE_NAME = TC.TABLE_NAME 108 | AND FKCU.REFERENCED_TABLE_NAME IS NOT NULL 109 | ORDER BY FKCU.ORDINAL_POSITION), 110 | NULL 111 | ) 112 | ) 113 | ), 114 | JSON_ARRAY() 115 | ) 116 | FROM 117 | INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC 118 | LEFT JOIN 119 | INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS RC 120 | ON TC.CONSTRAINT_SCHEMA = RC.CONSTRAINT_SCHEMA 121 | AND TC.CONSTRAINT_NAME = RC.CONSTRAINT_NAME 122 | AND TC.TABLE_NAME = RC.TABLE_NAME 123 | WHERE 124 | TC.TABLE_SCHEMA = T.TABLE_SCHEMA AND TC.TABLE_NAME = T.TABLE_NAME 125 | ), 126 | 'indexes', ( 127 | SELECT 128 | IFNULL( 129 | JSON_ARRAYAGG( 130 | JSON_OBJECT( 131 | 'index_name', IndexData.INDEX_NAME, 132 | 'is_unique', IF(IndexData.NON_UNIQUE = 0, TRUE, FALSE), 133 | 'is_primary', IF(IndexData.INDEX_NAME = 'PRIMARY', TRUE, FALSE), 134 | 'index_columns', IFNULL(IndexData.INDEX_COLUMNS_ARRAY, JSON_ARRAY()) 135 | ) 136 | ), 137 | JSON_ARRAY() 138 | ) 139 | FROM ( 140 | SELECT 141 | S.TABLE_SCHEMA, 142 | S.TABLE_NAME, 143 | S.INDEX_NAME, 144 | MIN(S.NON_UNIQUE) AS NON_UNIQUE, 145 | JSON_ARRAYAGG(S.COLUMN_NAME) AS INDEX_COLUMNS_ARRAY 146 | FROM 147 | INFORMATION_SCHEMA.STATISTICS S 148 | GROUP BY 149 | S.TABLE_SCHEMA, S.TABLE_NAME, S.INDEX_NAME 150 | ) AS IndexData 151 | WHERE IndexData.TABLE_SCHEMA = T.TABLE_SCHEMA AND IndexData.TABLE_NAME = T.TABLE_NAME 152 | ORDER BY IndexData.INDEX_NAME 153 | ), 154 | 'triggers', ( 155 | SELECT 156 | IFNULL( 157 | JSON_ARRAYAGG( 158 | JSON_OBJECT( 159 | 'trigger_name', TR.TRIGGER_NAME, 160 | 'trigger_definition', TR.ACTION_STATEMENT 161 | ) 162 | ), 163 | JSON_ARRAY() 164 | ) 165 | FROM 166 | INFORMATION_SCHEMA.TRIGGERS TR 167 | WHERE 168 | TR.EVENT_OBJECT_SCHEMA = T.TABLE_SCHEMA AND TR.EVENT_OBJECT_TABLE = T.TABLE_NAME 169 | ORDER BY TR.TRIGGER_NAME 170 | ) 171 | ) 172 | USING utf8mb4) 173 | END AS object_details 174 | FROM 175 | INFORMATION_SCHEMA.TABLES T 176 | CROSS JOIN (SELECT @table_names := ?, @output_format := ?) AS variables 177 | WHERE 178 | T.TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys') 179 | AND (NULLIF(TRIM(@table_names), '') IS NULL OR FIND_IN_SET(T.TABLE_NAME, @table_names)) 180 | AND T.TABLE_TYPE = 'BASE TABLE' 181 | ORDER BY 182 | T.TABLE_SCHEMA, T.TABLE_NAME; 183 | ` 184 | 185 | func init() { 186 | if !tools.Register(kind, newConfig) { 187 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 188 | } 189 | } 190 | 191 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 192 | actual := Config{Name: name} 193 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 194 | return nil, err 195 | } 196 | return actual, nil 197 | } 198 | 199 | type compatibleSource interface { 200 | MySQLPool() *sql.DB 201 | } 202 | 203 | // validate compatible sources are still compatible 204 | var _ compatibleSource = &cloudsqlmysql.Source{} 205 | var _ compatibleSource = &mysql.Source{} 206 | 207 | var compatibleSources = [...]string{cloudsqlmysql.SourceKind, mysql.SourceKind} 208 | 209 | type Config struct { 210 | Name string `yaml:"name" validate:"required"` 211 | Kind string `yaml:"kind" validate:"required"` 212 | Source string `yaml:"source" validate:"required"` 213 | Description string `yaml:"description" validate:"required"` 214 | AuthRequired []string `yaml:"authRequired"` 215 | } 216 | 217 | // validate interface 218 | var _ tools.ToolConfig = Config{} 219 | 220 | func (cfg Config) ToolConfigKind() string { 221 | return kind 222 | } 223 | 224 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 225 | // verify source exists 226 | rawS, ok := srcs[cfg.Source] 227 | if !ok { 228 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 229 | } 230 | 231 | // verify the source is compatible 232 | s, ok := rawS.(compatibleSource) 233 | if !ok { 234 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) 235 | } 236 | 237 | allParameters := tools.Parameters{ 238 | tools.NewStringParameterWithDefault("table_names", "", "Optional: A comma-separated list of table names. If empty, details for all tables will be listed."), 239 | tools.NewStringParameterWithDefault("output_format", "detailed", "Optional: Use 'simple' for names only or 'detailed' for full info."), 240 | } 241 | paramManifest := allParameters.Manifest() 242 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters) 243 | 244 | // finish tool setup 245 | t := Tool{ 246 | Name: cfg.Name, 247 | Kind: kind, 248 | AllParams: allParameters, 249 | AuthRequired: cfg.AuthRequired, 250 | Pool: s.MySQLPool(), 251 | manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, 252 | mcpManifest: mcpManifest, 253 | } 254 | return t, nil 255 | } 256 | 257 | // validate interface 258 | var _ tools.Tool = Tool{} 259 | 260 | type Tool struct { 261 | Name string `yaml:"name"` 262 | Kind string `yaml:"kind"` 263 | AuthRequired []string `yaml:"authRequired"` 264 | AllParams tools.Parameters `yaml:"allParams"` 265 | 266 | Pool *sql.DB 267 | manifest tools.Manifest 268 | mcpManifest tools.McpManifest 269 | } 270 | 271 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 272 | paramsMap := params.AsMap() 273 | 274 | tableNames, ok := paramsMap["table_names"].(string) 275 | if !ok { 276 | return nil, fmt.Errorf("invalid '%s' parameter; expected a string", tableNames) 277 | } 278 | outputFormat, _ := paramsMap["output_format"].(string) 279 | if outputFormat != "simple" && outputFormat != "detailed" { 280 | return nil, fmt.Errorf("invalid value for output_format: must be 'simple' or 'detailed', but got %q", outputFormat) 281 | } 282 | 283 | results, err := t.Pool.QueryContext(ctx, listTablesStatement, tableNames, outputFormat) 284 | if err != nil { 285 | return nil, fmt.Errorf("unable to execute query: %w", err) 286 | } 287 | 288 | cols, err := results.Columns() 289 | if err != nil { 290 | return nil, fmt.Errorf("unable to retrieve rows column name: %w", err) 291 | } 292 | 293 | // create an array of values for each column, which can be re-used to scan each row 294 | rawValues := make([]any, len(cols)) 295 | values := make([]any, len(cols)) 296 | for i := range rawValues { 297 | values[i] = &rawValues[i] 298 | } 299 | defer results.Close() 300 | 301 | colTypes, err := results.ColumnTypes() 302 | if err != nil { 303 | return nil, fmt.Errorf("unable to get column types: %w", err) 304 | } 305 | 306 | var out []any 307 | for results.Next() { 308 | err := results.Scan(values...) 309 | if err != nil { 310 | return nil, fmt.Errorf("unable to parse row: %w", err) 311 | } 312 | vMap := make(map[string]any) 313 | for i, name := range cols { 314 | val := rawValues[i] 315 | if val == nil { 316 | vMap[name] = nil 317 | continue 318 | } 319 | 320 | vMap[name], err = mysqlcommon.ConvertToType(colTypes[i], val) 321 | if err != nil { 322 | return nil, fmt.Errorf("errors encountered when converting values: %w", err) 323 | } 324 | } 325 | out = append(out, vMap) 326 | } 327 | 328 | if err := results.Err(); err != nil { 329 | return nil, fmt.Errorf("errors encountered during row iteration: %w", err) 330 | } 331 | 332 | return out, nil 333 | } 334 | 335 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 336 | return tools.ParseParams(t.AllParams, data, claims) 337 | } 338 | 339 | func (t Tool) Manifest() tools.Manifest { 340 | return t.manifest 341 | } 342 | 343 | func (t Tool) McpManifest() tools.McpManifest { 344 | return t.mcpManifest 345 | } 346 | 347 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 348 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 349 | } 350 | 351 | func (t Tool) RequiresClientAuthorization() bool { 352 | return false 353 | } 354 | ``` -------------------------------------------------------------------------------- /internal/tools/looker/lookerhealthpulse/lookerhealthpulse.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 lookerhealthpulse 15 | 16 | import ( 17 | "context" 18 | "encoding/json" 19 | "fmt" 20 | "strings" 21 | 22 | yaml "github.com/goccy/go-yaml" 23 | "github.com/googleapis/genai-toolbox/internal/sources" 24 | lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker" 25 | "github.com/googleapis/genai-toolbox/internal/tools" 26 | "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon" 27 | "github.com/googleapis/genai-toolbox/internal/util" 28 | 29 | "github.com/looker-open-source/sdk-codegen/go/rtl" 30 | v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" 31 | ) 32 | 33 | // ================================================================================================================= 34 | // START MCP SERVER CORE LOGIC 35 | // ================================================================================================================= 36 | const kind string = "looker-health-pulse" 37 | 38 | func init() { 39 | if !tools.Register(kind, newConfig) { 40 | panic(fmt.Sprintf("tool kind %q already registered", kind)) 41 | } 42 | } 43 | 44 | func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { 45 | actual := Config{Name: name} 46 | if err := decoder.DecodeContext(ctx, &actual); err != nil { 47 | return nil, err 48 | } 49 | return actual, nil 50 | } 51 | 52 | type Config struct { 53 | Name string `yaml:"name" validate:"required"` 54 | Kind string `yaml:"kind" validate:"required"` 55 | Source string `yaml:"source" validate:"required"` 56 | Description string `yaml:"description" validate:"required"` 57 | AuthRequired []string `yaml:"authRequired"` 58 | Parameters map[string]any `yaml:"parameters"` 59 | } 60 | 61 | // validate interface 62 | var _ tools.ToolConfig = Config{} 63 | 64 | func (cfg Config) ToolConfigKind() string { 65 | return kind 66 | } 67 | 68 | func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { 69 | // verify source exists 70 | rawS, ok := srcs[cfg.Source] 71 | if !ok { 72 | return nil, fmt.Errorf("no source named %q configured", cfg.Source) 73 | } 74 | 75 | // verify the source is compatible 76 | s, ok := rawS.(*lookersrc.Source) 77 | if !ok { 78 | return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind) 79 | } 80 | 81 | actionParameter := tools.NewStringParameterWithRequired("action", "The health check to run. Can be either: `check_db_connections`, `check_dashboard_performance`,`check_dashboard_errors`,`check_explore_performance`,`check_schedule_failures`, or `check_legacy_features`", true) 82 | 83 | parameters := tools.Parameters{ 84 | actionParameter, 85 | } 86 | 87 | mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters) 88 | 89 | // finish tool setup 90 | return Tool{ 91 | Name: cfg.Name, 92 | Kind: kind, 93 | Parameters: parameters, 94 | AuthRequired: cfg.AuthRequired, 95 | UseClientOAuth: s.UseClientOAuth, 96 | Client: s.Client, 97 | ApiSettings: s.ApiSettings, 98 | manifest: tools.Manifest{ 99 | Description: cfg.Description, 100 | Parameters: parameters.Manifest(), 101 | AuthRequired: cfg.AuthRequired, 102 | }, 103 | mcpManifest: mcpManifest, 104 | }, nil 105 | } 106 | 107 | // validate interface 108 | var _ tools.Tool = Tool{} 109 | 110 | type Tool struct { 111 | Name string `yaml:"name"` 112 | Kind string `yaml:"kind"` 113 | UseClientOAuth bool 114 | Client *v4.LookerSDK 115 | ApiSettings *rtl.ApiSettings 116 | AuthRequired []string `yaml:"authRequired"` 117 | Parameters tools.Parameters `yaml:"parameters"` 118 | manifest tools.Manifest 119 | mcpManifest tools.McpManifest 120 | } 121 | 122 | func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { 123 | logger, err := util.LoggerFromContext(ctx) 124 | if err != nil { 125 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 126 | } 127 | 128 | sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken) 129 | if err != nil { 130 | return nil, fmt.Errorf("error getting sdk: %w", err) 131 | } 132 | 133 | pulseTool := &pulseTool{ 134 | ApiSettings: t.ApiSettings, 135 | SdkClient: sdk, 136 | } 137 | 138 | paramsMap := params.AsMap() 139 | action, ok := paramsMap["action"].(string) 140 | if !ok { 141 | return nil, fmt.Errorf("action parameter not found") 142 | } 143 | 144 | pulseParams := PulseParams{ 145 | Action: action, 146 | } 147 | 148 | result, err := pulseTool.RunPulse(ctx, pulseParams) 149 | if err != nil { 150 | return nil, fmt.Errorf("error running pulse: %w", err) 151 | } 152 | 153 | logger.DebugContext(ctx, "result = ", result) 154 | 155 | return result, nil 156 | } 157 | 158 | func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { 159 | return tools.ParseParams(t.Parameters, data, claims) 160 | } 161 | 162 | func (t Tool) Manifest() tools.Manifest { 163 | return t.manifest 164 | } 165 | 166 | func (t Tool) McpManifest() tools.McpManifest { 167 | return t.mcpManifest 168 | } 169 | 170 | func (t Tool) Authorized(verifiedAuthServices []string) bool { 171 | return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) 172 | } 173 | 174 | func (t Tool) RequiresClientAuthorization() bool { 175 | return t.UseClientOAuth 176 | } 177 | 178 | // ================================================================================================================= 179 | // END MCP SERVER CORE LOGIC 180 | // ================================================================================================================= 181 | 182 | // ================================================================================================================= 183 | // START LOOKER HEALTH PULSE CORE LOGIC 184 | // ================================================================================================================= 185 | type PulseParams struct { 186 | Action string 187 | // Optionally add more parameters if needed 188 | } 189 | 190 | // pulseTool holds Looker API settings and client 191 | type pulseTool struct { 192 | ApiSettings *rtl.ApiSettings 193 | SdkClient *v4.LookerSDK 194 | } 195 | 196 | func (t *pulseTool) RunPulse(ctx context.Context, params PulseParams) (interface{}, error) { 197 | switch params.Action { 198 | case "check_db_connections": 199 | return t.checkDBConnections(ctx) 200 | case "check_dashboard_performance": 201 | return t.checkDashboardPerformance(ctx) 202 | case "check_dashboard_errors": 203 | return t.checkDashboardErrors(ctx) 204 | case "check_explore_performance": 205 | return t.checkExplorePerformance(ctx) 206 | case "check_schedule_failures": 207 | return t.checkScheduleFailures(ctx) 208 | case "check_legacy_features": 209 | return t.checkLegacyFeatures(ctx) 210 | default: 211 | return nil, fmt.Errorf("unknown action: %s", params.Action) 212 | } 213 | } 214 | 215 | // Check DB connections and run tests 216 | func (t *pulseTool) checkDBConnections(ctx context.Context) (interface{}, error) { 217 | logger, err := util.LoggerFromContext(ctx) 218 | if err != nil { 219 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 220 | } 221 | logger.InfoContext(ctx, "Test 1/6: Checking connections") 222 | 223 | reservedNames := map[string]struct{}{ 224 | "looker__internal__analytics__replica": {}, 225 | "looker__internal__analytics": {}, 226 | "looker": {}, 227 | "looker__ilooker": {}, 228 | } 229 | 230 | connections, err := t.SdkClient.AllConnections("", t.ApiSettings) 231 | if err != nil { 232 | return nil, fmt.Errorf("error fetching connections: %w", err) 233 | } 234 | 235 | var filteredConnections []v4.DBConnection 236 | for _, c := range connections { 237 | if _, reserved := reservedNames[*c.Name]; !reserved { 238 | filteredConnections = append(filteredConnections, c) 239 | } 240 | } 241 | if len(filteredConnections) == 0 { 242 | return nil, fmt.Errorf("no connections found") 243 | } 244 | 245 | var results []map[string]interface{} 246 | for _, conn := range filteredConnections { 247 | var errors []string 248 | // Test connection (simulate test_connection endpoint) 249 | resp, err := t.SdkClient.TestConnection(*conn.Name, nil, t.ApiSettings) 250 | if err != nil { 251 | errors = append(errors, "API JSONDecode Error") 252 | } else { 253 | for _, r := range resp { 254 | if *r.Status == "error" { 255 | errors = append(errors, *r.Message) 256 | } 257 | } 258 | } 259 | 260 | // Run inline query for connection activity 261 | limit := "1" 262 | query := &v4.WriteQuery{ 263 | Model: "system__activity", 264 | View: "history", 265 | Fields: &[]string{"history.query_run_count"}, 266 | Filters: &map[string]any{ 267 | "history.connection_name": *conn.Name, 268 | "history.created_date": "90 days", 269 | "user.dev_branch_name": "NULL", 270 | }, 271 | Limit: &limit, 272 | } 273 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 274 | if err != nil { 275 | return nil, err 276 | } 277 | var queryRunCount interface{} 278 | var data []map[string]interface{} 279 | _ = json.Unmarshal([]byte(raw), &data) 280 | if len(data) > 0 { 281 | queryRunCount = data[0]["history.query_run_count"] 282 | } 283 | 284 | results = append(results, map[string]interface{}{ 285 | "Connection": *conn.Name, 286 | "Status": "OK", 287 | "Errors": errors, 288 | "Query Count": queryRunCount, 289 | }) 290 | } 291 | return results, nil 292 | } 293 | 294 | func (t *pulseTool) checkDashboardPerformance(ctx context.Context) (interface{}, error) { 295 | logger, err := util.LoggerFromContext(ctx) 296 | if err != nil { 297 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 298 | } 299 | logger.InfoContext(ctx, "Test 2/6: Checking for dashboards with queries slower than 30 seconds in the last 7 days") 300 | 301 | limit := "20" 302 | query := &v4.WriteQuery{ 303 | Model: "system__activity", 304 | View: "history", 305 | Fields: &[]string{"dashboard.title", "query.count"}, 306 | Filters: &map[string]any{ 307 | "history.created_date": "7 days", 308 | "history.real_dash_id": "-NULL", 309 | "history.runtime": ">30", 310 | "history.status": "complete", 311 | }, 312 | Sorts: &[]string{"query.count desc"}, 313 | Limit: &limit, 314 | } 315 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 316 | if err != nil { 317 | return nil, err 318 | } 319 | var dashboards []map[string]interface{} 320 | if err := json.Unmarshal([]byte(raw), &dashboards); err != nil { 321 | return nil, err 322 | } 323 | return dashboards, nil 324 | } 325 | 326 | func (t *pulseTool) checkDashboardErrors(ctx context.Context) (interface{}, error) { 327 | logger, err := util.LoggerFromContext(ctx) 328 | if err != nil { 329 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 330 | } 331 | logger.InfoContext(ctx, "Test 3/6: Checking for dashboards with erroring queries in the last 7 days") 332 | 333 | limit := "20" 334 | query := &v4.WriteQuery{ 335 | Model: "system__activity", 336 | View: "history", 337 | Fields: &[]string{"dashboard.title", "history.query_run_count"}, 338 | Filters: &map[string]any{ 339 | "dashboard.title": "-NULL", 340 | "history.created_date": "7 days", 341 | "history.dashboard_session": "-NULL", 342 | "history.status": "error", 343 | }, 344 | Sorts: &[]string{"history.query_run_count desc"}, 345 | Limit: &limit, 346 | } 347 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 348 | if err != nil { 349 | return nil, err 350 | } 351 | var dashboards []map[string]interface{} 352 | if err := json.Unmarshal([]byte(raw), &dashboards); err != nil { 353 | return nil, err 354 | } 355 | return dashboards, nil 356 | } 357 | 358 | func (t *pulseTool) checkExplorePerformance(ctx context.Context) (interface{}, error) { 359 | logger, err := util.LoggerFromContext(ctx) 360 | if err != nil { 361 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 362 | } 363 | logger.InfoContext(ctx, "Test 4/6: Checking for the slowest explores in the past 7 days") 364 | 365 | limit := "20" 366 | query := &v4.WriteQuery{ 367 | Model: "system__activity", 368 | View: "history", 369 | Fields: &[]string{"query.model", "query.view", "history.average_runtime"}, 370 | Filters: &map[string]any{ 371 | "history.created_date": "7 days", 372 | "query.model": "-NULL, -system^_^_activity", 373 | }, 374 | Sorts: &[]string{"history.average_runtime desc"}, 375 | Limit: &limit, 376 | } 377 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 378 | if err != nil { 379 | return nil, err 380 | } 381 | var explores []map[string]interface{} 382 | if err := json.Unmarshal([]byte(raw), &explores); err != nil { 383 | return nil, err 384 | } 385 | 386 | // Average query runtime 387 | query.Fields = &[]string{"history.average_runtime"} 388 | rawAvg, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 389 | if err != nil { 390 | return nil, err 391 | } 392 | var avgData []map[string]interface{} 393 | if err := json.Unmarshal([]byte(rawAvg), &avgData); err == nil { 394 | if len(avgData) > 0 { 395 | if avgRuntime, ok := avgData[0]["history.average_runtime"].(float64); ok { 396 | logger.InfoContext(ctx, fmt.Sprintf("For context, the average query runtime is %.4fs", avgRuntime)) 397 | } 398 | } 399 | } 400 | return explores, nil 401 | } 402 | 403 | func (t *pulseTool) checkScheduleFailures(ctx context.Context) (interface{}, error) { 404 | logger, err := util.LoggerFromContext(ctx) 405 | if err != nil { 406 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 407 | } 408 | logger.InfoContext(ctx, "Test 5/6: Checking for failing schedules") 409 | 410 | limit := "500" 411 | query := &v4.WriteQuery{ 412 | Model: "system__activity", 413 | View: "scheduled_plan", 414 | Fields: &[]string{"scheduled_job.name", "scheduled_job.count"}, 415 | Filters: &map[string]any{ 416 | "scheduled_job.created_date": "7 days", 417 | "scheduled_job.status": "failure", 418 | }, 419 | Sorts: &[]string{"scheduled_job.count desc"}, 420 | Limit: &limit, 421 | } 422 | raw, err := lookercommon.RunInlineQuery(ctx, t.SdkClient, query, "json", t.ApiSettings) 423 | if err != nil { 424 | return nil, err 425 | } 426 | var schedules []map[string]interface{} 427 | if err := json.Unmarshal([]byte(raw), &schedules); err != nil { 428 | return nil, err 429 | } 430 | return schedules, nil 431 | } 432 | 433 | func (t *pulseTool) checkLegacyFeatures(ctx context.Context) (interface{}, error) { 434 | logger, err := util.LoggerFromContext(ctx) 435 | if err != nil { 436 | return nil, fmt.Errorf("unable to get logger from ctx: %s", err) 437 | } 438 | logger.InfoContext(ctx, "Test 6/6: Checking for enabled legacy features") 439 | 440 | features, err := t.SdkClient.AllLegacyFeatures(t.ApiSettings) 441 | if err != nil { 442 | if strings.Contains(err.Error(), "Unsupported in Looker (Google Cloud core)") { 443 | return []map[string]string{{"Feature": "Unsupported in Looker (Google Cloud core)"}}, nil 444 | } 445 | logger.ErrorContext(ctx, err.Error()) 446 | return []map[string]string{{"Feature": "Unable to pull legacy features due to SDK error"}}, nil 447 | } 448 | var legacyFeatures []map[string]string 449 | for _, f := range features { 450 | if *f.Enabled { 451 | legacyFeatures = append(legacyFeatures, map[string]string{"Feature": *f.Name}) 452 | } 453 | } 454 | return legacyFeatures, nil 455 | } 456 | 457 | // ================================================================================================================= 458 | // END LOOKER HEALTH PULSE CORE LOGIC 459 | // ================================================================================================================= 460 | ```