Compare commits

...

22 Commits

Author SHA1 Message Date
Prerna Kakkar
3117c78af6 add base url check 2025-09-10 12:59:28 +00:00
Prerna Kakkar
738cca7c61 Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-10 12:58:18 +00:00
Anmol Shukla
302faf2513 docs: updated google api key conf (#1398)
Co-authored-by: Twisha Bansal <58483338+twishabansal@users.noreply.github.com>
2025-09-10 12:24:19 +05:30
Mend Renovate
22cf228b88 chore(deps): update module github.com/googleapis/mcp-toolbox-sdk-go to v0.3.0 (#1360)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[github.com/googleapis/mcp-toolbox-sdk-go](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go)
| `v0.2.0` -> `v0.3.0` |
[![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgoogleapis%2fmcp-toolbox-sdk-go/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgoogleapis%2fmcp-toolbox-sdk-go/v0.2.0/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>googleapis/mcp-toolbox-sdk-go
(github.com/googleapis/mcp-toolbox-sdk-go)</summary>

###
[`v0.3.0`](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go/releases/tag/v0.3.0)

[Compare
Source](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go/compare/v0.2.0...v0.3.0)

##### Features

- Add support for Map parameter type
([#&#8203;51](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go/issues/51))
([c80d8d6](c80d8d6e2b))
- Add support for nested maps in generic map parameter
([#&#8203;61](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go/issues/61))
([3b33c52](3b33c52150))

##### Miscellaneous Chores

- Release 0.3.0
([#&#8203;59](https://redirect.github.com/googleapis/mcp-toolbox-sdk-go/issues/59))
([a89adcf](a89adcf440))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/googleapis/genai-toolbox).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45MS4xIiwidXBkYXRlZEluVmVyIjoiNDEuOTcuMTAiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Twisha Bansal <58483338+twishabansal@users.noreply.github.com>
2025-09-10 06:41:00 +00:00
Yuan Teoh
794ad91885 chore: fix lint in recent merged prs (#1396)
Run `golangcilint` on recent merged prs.
2025-09-09 22:21:40 +00:00
Wenxin Du
b5f9780a59 fix(bigquery)!: Add Bearer parsing to auth token (#1386)
Previously we propagate tokens directly to the BQ API. But MCP inspector
adds a "Bearer" prefix to all authorization header. We will need to
parse the token accordingly to make it work.
2025-09-09 15:47:52 -04:00
trehanshakuntG
cce602f280 feat(tools/firestore): Add firestore-query tool (#1305)
## Description
---

This PR introduces a new tool kind `firestore-query` that enables
parameterized querying of Firestore collections with support for
Firestore native JSON value types, ensuring proper type handling for
complex queries.

### Feature

A new Firestore tool that allows:

- __Parameterized collection paths, filters, select, orderBy, limit and
analyzeQuery__ using Go template syntax
- __Native JSON value type support__ for proper type handling in queries
- __Complex filter structures__ with AND/OR logical operators
- __Dynamic query building__ with template parameter substitution

Example usage:
<img width="761" height="721" alt="Screenshot 2025-09-09 at 1 21 16 PM"
src="https://github.com/user-attachments/assets/bb359ea8-f750-492d-9f13-cef8f3b6bfd1"
/>



## PR Checklist
---
> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:
- [x] Make sure you reviewed

[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [x] Make sure to open an issue as a

[bug/issue](https://github.com/googleapis/langchain-google-alloydb-pg-python/issues/new/choose)
before writing your code! That way we can discuss the change, evaluate
  designs, and agree on the general idea
- [x] Ensure the tests and linter pass
- [x] Code coverage does not decrease (if any source code was changed)
- [x] Appropriate docs were updated (if necessary)
- [x] Make sure to add `!` if this involve a breaking change
2025-09-09 18:24:12 +00:00
Averi Kitsch
70e832bd08 feat(prebuilt): Update default values for prebuilt tools (#1355)
## Description
Provide default values for prebuilt tools

## PR Checklist

---
> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:

- [ ] Make sure you reviewed

[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [ ] Make sure to open an issue as a

[bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose)
before writing your code! That way we can discuss the change, evaluate
  designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)
- [ ] Make sure to add `!` if this involve a breaking change

🛠️ Fixes #<issue_number_goes_here>
2025-09-09 11:03:26 -07:00
Twisha Bansal
05b14a8824 ci: update blunderbuss to assign issues to blr team (#1387)
## Description

Issues will now be assigned to more people, thus allowing faster
triages.

## PR Checklist

> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:

- [x] Make sure you reviewed

[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [ ] Make sure to open an issue as a

[bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose)
before writing your code! That way we can discuss the change, evaluate
  designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)
- [ ] Make sure to add `!` if this involve a breaking change

🛠️ Fixes #<issue_number_goes_here>
2025-09-09 22:32:34 +05:30
Sfurti-yb
664711f4b3 feat(yugabytedb): Add YugabyteDB Source and Tool (#732)
- This PR aims to add YugabyteDB as a source and a tool.
- It is based on the PostgreSQL source but uses the YugabyteDB's fork of
pgx driver that better accommodates the distributed nature of YugabyteDB
- Added tests for the same.

---------

Co-authored-by: Amogh Shetkar <ashetkar@yugabyte.com>
2025-09-09 10:59:00 -04:00
Prerna Kakkar
9f69b3c8d1 update tests 2025-09-09 08:47:47 +00:00
Prerna Kakkar
47f7c9d918 Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-09 08:45:51 +00:00
Prerna Kakkar
07083e3664 add source 2025-09-08 16:45:13 +00:00
prernakakkar-google
a74c1b69ac Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-08 10:53:02 +00:00
Prerna Kakkar
79a6c827f7 Resolve comments 2025-09-08 10:49:59 +00:00
Prerna Kakkar
41ea1c488f Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-08 06:37:38 +00:00
Prerna Kakkar
afe6a2c4d2 resolve comments 2025-09-04 14:58:31 +00:00
Prerna Kakkar
16f9a418c3 Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-04 14:36:18 +00:00
Prerna Kakkar
669e6a3a07 add test 2025-09-02 17:23:05 +00:00
Prerna Kakkar
96ba178a1d Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-02 17:19:11 +00:00
Prerna Kakkar
cbe8b13936 Merge branch 'main' into cloud-sql-wait-for-operation 2025-09-02 07:50:18 +00:00
Prerna Kakkar
f4ffb2fe27 feat(tool/cloudsql): Add cloud sql wait for operation tool with exponential backoff 2025-09-02 07:49:52 +00:00
50 changed files with 4287 additions and 65 deletions

View File

@@ -531,6 +531,24 @@ steps:
utility \
utility/alloydbwaitforoperation
- id: "cloud-sql"
name: golang:1
waitFor: ["compile-test-binary"]
entrypoint: /bin/bash
env:
- "GOPATH=/gopath"
secretEnv: ["CLIENT_ID"]
volumes:
- name: "go"
path: "/gopath"
args:
- -c
- |
.ci/test_with_coverage.sh \
"Cloud SQL Wait for Operation" \
cloudsql \
cloudsql
- id: "tidb"
name: golang:1
waitFor: ["compile-test-binary"]
@@ -620,6 +638,25 @@ steps:
trino \
trinosql trinoexecutesql
- id: "yugabytedb"
name: golang:1
waitFor: ["compile-test-binary"]
entrypoint: /bin/bash
env:
- "GOPATH=/gopath"
- "YUGABYTEDB_DATABASE=$_YUGABYTEDB_DATABASE"
- "YUGABYTEDB_PORT=$_YUGABYTEDB_PORT"
- "YUGABYTEDB_LOADBALANCE=$_YUGABYTEDB_LOADBALANCE"
- "SERVICE_ACCOUNT_EMAIL=$SERVICE_ACCOUNT_EMAIL"
secretEnv: ["YUGABYTEDB_USER", "YUGABYTEDB_PASS", "YUGABYTEDB_HOST", "CLIENT_ID"]
volumes:
- name: "go"
path: "/gopath"
args:
- -c
- |
./yugabytedb.test -test.v
availableSecrets:
secretManager:
- versionName: projects/$PROJECT_ID/secrets/cloud_sql_pg_user/versions/latest
@@ -698,6 +735,13 @@ availableSecrets:
env: OCEANBASE_USER
- versionName: projects/$PROJECT_ID/secrets/oceanbase_pass/versions/latest
env: OCEANBASE_PASSWORD
- versionName: projects/$PROJECT_ID/secrets/yugabytedb_host/versions/latest
env: YUGABYTEDB_HOST
- versionName: projects/$PROJECT_ID/secrets/yugabytedb_user/versions/latest
env: YUGABYTEDB_USER
- versionName: projects/$PROJECT_ID/secrets/yugabytedb_pass/versions/latest
env: YUGABYTEDB_PASS
options:
logging: CLOUD_LOGGING_ONLY
@@ -744,3 +788,6 @@ substitutions:
_TRINO_SCHEMA: "default"
_OCEANBASE_PORT: "2883"
_OCEANBASE_DATABASE: "oceanbase"
_YUGABYTEDB_DATABASE: "yugabyte"
_YUGABYTEDB_PORT: "5433"
_YUGABYTEDB_LOADBALANCE: "false"

View File

@@ -1,7 +1,24 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
assign_issues:
- Yuan325
- duwenxin99
- averikitsch
- anubhav756
- dishaprakash
- twishabansal
assign_issues_by:
- labels:
- 'product: bigquery'

View File

@@ -54,6 +54,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/bigtable"
_ "github.com/googleapis/genai-toolbox/internal/tools/clickhouse/clickhouseexecutesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/clickhouse/clickhousesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlwaitforoperation"
_ "github.com/googleapis/genai-toolbox/internal/tools/couchbase"
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexlookupentry"
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexsearchaspecttypes"
@@ -66,6 +67,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetdocuments"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetrules"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorelistcollections"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequery"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequerycollection"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreupdatedocument"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorevalidaterules"
@@ -116,6 +118,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/utility/alloydbwaitforoperation"
_ "github.com/googleapis/genai-toolbox/internal/tools/utility/wait"
_ "github.com/googleapis/genai-toolbox/internal/tools/valkey"
_ "github.com/googleapis/genai-toolbox/internal/tools/yugabytedbsql"
"github.com/spf13/cobra"
@@ -145,6 +148,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/sources/tidb"
_ "github.com/googleapis/genai-toolbox/internal/sources/trino"
_ "github.com/googleapis/genai-toolbox/internal/sources/valkey"
_ "github.com/googleapis/genai-toolbox/internal/sources/yugabytedb"
)
var (

View File

@@ -3,7 +3,7 @@ module genai-quickstart
go 1.24.6
require (
github.com/googleapis/mcp-toolbox-sdk-go v0.2.0
github.com/googleapis/mcp-toolbox-sdk-go v0.3.0
google.golang.org/genai v1.21.0
)

View File

@@ -4,7 +4,7 @@ go 1.24.6
require (
github.com/firebase/genkit/go v0.6.2
github.com/googleapis/mcp-toolbox-sdk-go v0.2.0
github.com/googleapis/mcp-toolbox-sdk-go v0.3.0
)
require (

View File

@@ -3,7 +3,7 @@ module langchan-quickstart
go 1.24.6
require (
github.com/googleapis/mcp-toolbox-sdk-go v0.2.0
github.com/googleapis/mcp-toolbox-sdk-go v0.3.0
github.com/tmc/langchaingo v0.1.13
)

View File

@@ -3,7 +3,7 @@ module openai-quickstart
go 1.24.6
require (
github.com/googleapis/mcp-toolbox-sdk-go v0.2.0
github.com/googleapis/mcp-toolbox-sdk-go v0.3.0
github.com/openai/openai-go v1.12.0
)

View File

@@ -3,7 +3,7 @@ import { ToolboxClient } from "@toolbox-sdk/core";
const TOOLBOX_URL = "http://127.0.0.1:5000"; // Update if needed
const GOOGLE_API_KEY = 'enter your api here'; // Replace it with your API key
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || 'your-api-key'; // Replace it with your API key
const prompt = `
You're a helpful hotel assistant. You handle hotel searching, booking, and

View File

@@ -2,8 +2,7 @@ import { ToolboxClient } from "@toolbox-sdk/core";
import { genkit } from "genkit";
import { googleAI } from '@genkit-ai/googleai';
// Replace it with your API key
process.env.GOOGLE_API_KEY = 'your-api-key';
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || 'your-api-key'; // Replace it with your API key
const systemPrompt = `
You're a helpful hotel assistant. You handle hotel searching, booking, and
@@ -28,7 +27,7 @@ async function main() {
const ai = genkit({
plugins: [
googleAI({
apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
apiKey: process.env.GEMINI_API_KEY || GOOGLE_API_KEY
})
],
model: googleAI.model('gemini-2.0-flash'),
@@ -86,4 +85,4 @@ async function main() {
}
}
main();
main();

View File

@@ -4,8 +4,7 @@ import { tool } from "@langchain/core/tools";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { MemorySaver } from "@langchain/langgraph";
// Replace it with your API key
process.env.GOOGLE_API_KEY = 'your-api-key';
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || 'your-api-key'; // Replace it with your API key
const prompt = `
You're a helpful hotel assistant. You handle hotel searching, booking, and

View File

@@ -4,7 +4,7 @@ import { createMemory, staticBlock, tool } from "llamaindex";
import { ToolboxClient } from "@toolbox-sdk/core";
const TOOLBOX_URL = "http://127.0.0.1:5000"; // Update if needed
process.env.GOOGLE_API_KEY = 'your-api-key'; // Replace it with your API key
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || 'your-api-key'; // Replace it with your API key
const prompt = `
@@ -40,7 +40,7 @@ async function main() {
// Initialize LLM
const llm = gemini({
model: GEMINI_MODEL.GEMINI_2_0_FLASH,
apiKey: process.env.GOOGLE_API_KEY,
apiKey: GOOGLE_API_KEY,
});
const memory = createMemory({

View File

@@ -19,10 +19,9 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `ALLOYDB_POSTGRES_CLUSTER`: The ID of your AlloyDB cluster.
* `ALLOYDB_POSTGRES_INSTANCE`: The ID of your AlloyDB instance.
* `ALLOYDB_POSTGRES_DATABASE`: The name of the database to connect to.
* `ALLOYDB_POSTGRES_USER`: The database username. Defaults to IAM authentication if unspecified.
* `ALLOYDB_POSTGRES_PASSWORD`: The password for the database user. Defaults to IAM authentication if unspecified.
* `ALLOYDB_POSTGRES_IP_TYPE`: The IP type i.e. "Public
or "Private" (Default: Public).
* `ALLOYDB_POSTGRES_USER`: (Optional) The database username. Defaults to IAM authentication if unspecified.
* `ALLOYDB_POSTGRES_PASSWORD`: (Optional) The password for the database user. Defaults to IAM authentication if unspecified.
* `ALLOYDB_POSTGRES_IP_TYPE`: (Optional) The IP type i.e. "Public" or "Private" (Default: Public).
* **Permissions:**
* **AlloyDB Client** (`roles/alloydb.client`) to connect to the instance.
* Database-level permissions (e.g., `SELECT`, `INSERT`) are required to execute queries.
@@ -51,6 +50,7 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `--prebuilt` value: `bigquery`
* **Environment Variables:**
* `BIGQUERY_PROJECT`: The GCP project ID.
* `BIGQUERY_LOCATION`: (Optional) The dataset location.
* **Permissions:**
* **BigQuery User** (`roles/bigquery.user`) to execute queries and view metadata.
* **BigQuery Metadata Viewer** (`roles/bigquery.metadataViewer`) to view all datasets.
@@ -90,8 +90,9 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `CLOUD_SQL_POSTGRES_REGION`: The region of your Cloud SQL instance.
* `CLOUD_SQL_POSTGRES_INSTANCE`: The ID of your Cloud SQL instance.
* `CLOUD_SQL_POSTGRES_DATABASE`: The name of the database to connect to.
* `CLOUD_SQL_POSTGRES_USER`: The database username.
* `CLOUD_SQL_POSTGRES_PASSWORD`: The password for the database user.
* `CLOUD_SQL_POSTGRES_USER`: (Optional) The database username. Defaults to IAM authentication if unspecified.
* `CLOUD_SQL_POSTGRES_PASSWORD`: (Optional) The password for the database user. Defaults to IAM authentication if unspecified.
* `CLOUD_SQL_POSTGRES_IP_TYPE`: (Optional) The IP type i.e. "Public" or "Private" (Default: Public).
* **Permissions:**
* **Cloud SQL Client** (`roles/cloudsql.client`) to connect to the instance.
* Database-level permissions (e.g., `SELECT`, `INSERT`) are required to execute queries.
@@ -110,6 +111,7 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `CLOUD_SQL_MSSQL_IP_ADDRESS`: The IP address of the Cloud SQL instance.
* `CLOUD_SQL_MSSQL_USER`: The database username.
* `CLOUD_SQL_MSSQL_PASSWORD`: The password for the database user.
* `CLOUD_SQL_MSSQL_IP_TYPE`: (Optional) The IP type i.e. "Public" or "Private" (Default: Public).
* **Permissions:**
* **Cloud SQL Client** (`roles/cloudsql.client`) to connect to the instance.
* Database-level permissions (e.g., `SELECT`, `INSERT`) are required to execute queries.
@@ -135,7 +137,7 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `--prebuilt` value: `firestore`
* **Environment Variables:**
* `FIRESTORE_PROJECT`: The GCP project ID.
* `FIRESTORE_DATABASE`: The Firestore database ID.
* `FIRESTORE_DATABASE`: (Optional) The Firestore database ID. Defaults to "(default)".
* **Permissions:**
* **Cloud Datastore User** (`roles/datastore.user`) to get documents, list collections, and query collections.
* **Firebase Rules Viewer** (`roles/firebaserules.viewer`) to get and validate Firestore rules.
@@ -228,6 +230,7 @@ See guides, [Connect from your IDE](../how-to/connect-ide/_index.md), for detail
* `POSTGRES_DATABASE`: The name of the database to connect to.
* `POSTGRES_USER`: The database username.
* `POSTGRES_PASSWORD`: The password for the database user.
* `POSTGRES_QUERY_PARAMS`: (Optional) Raw query to be added to the db connection string.
* **Permissions:**
* Database-level permissions (e.g., `SELECT`, `INSERT`) are required to execute queries.
* **Tools:**

View File

@@ -0,0 +1,44 @@
---
title: "YugabyteDB"
type: docs
weight: 1
description: >
YugabyteDB is a high-performance, distributed SQL database.
---
## About
[YugabyteDB][yugabytedb] is a high-performance, distributed SQL database designed for global, internet-scale applications, with full PostgreSQL compatibility.
[yugabytedb]: https://www.yugabyte.com/
## Example
```yaml
sources:
my-yb-source:
kind: yugabytedb
host: 127.0.0.1
port: 5433
database: yugabyte
user: ${USER_NAME}
password: ${PASSWORD}
loadBalance: true
topologyKeys: cloud.region.zone1:1,cloud.region.zone2:2
```
## Reference
| **field** | **type** | **required** | **description** |
|-----------------------------------|:--------:|:------------:|------------------------------------------------------------------------|
| kind | string | true | Must be "yugabytedb". |
| host | string | true | IP address to connect to. |
| port | integer | true | Port to connect to. The default port is 5433. |
| database | string | true | Name of the YugabyteDB database to connect to. The default database name is yugabyte. |
| user | string | true | Name of the YugabyteDB user to connect as. The default user is yugabyte. |
| password | string | true | Password of the YugabyteDB user. The default password is yugabyte. |
| loadBalance | boolean | false | If true, enable uniform load balancing. The default loadBalance value is false. |
| topologyKeys | string | false | Comma-separated geo-locations in the form cloud.region.zone:priority to enable topology-aware load balancing. Ignored if loadBalance is false. It is null by default. |
| ybServersRefreshInterval | integer | false | The interval (in seconds) to refresh the servers list; ignored if loadBalance is false. The default value of ybServersRefreshInterval is 300. |
| fallbackToTopologyKeysOnly | boolean | false | If set to true and topologyKeys are specified, only connect to nodes specified in topologyKeys. By defualt, this is set to false. |
| failedHostReconnectDelaySecs | integer | false | Time (in seconds) to wait before trying to connect to failed nodes. The default value of is 5. |

View File

@@ -0,0 +1,7 @@
---
title: "Cloud SQL"
type: docs
weight: 1
description: >
Tools that work with Cloud SQL Control Plane.
---

View File

@@ -0,0 +1,43 @@
---
title: "cloud-sql-wait-for-operation"
type: docs
weight: 10
description: >
Wait for a long-running Cloud SQL operation to complete.
---
The `cloud-sql-wait-for-operation` tool is a utility tool that waits for a
long-running Cloud SQL operation to complete. It does this by polling the Cloud
SQL Admin API operation status endpoint until the operation is finished, using
exponential backoff.
{{< notice info >}}
This tool is intended for developer assistant workflows with human-in-the-loop
and shouldn't be used for production agents.
{{< /notice >}}
## Example
```yaml
tools:
cloudsql-operations-get:
kind: cloud-sql-wait-for-operation
source: some-http-source
description: "This will poll on operations API until the operation is done. For checking operation status we need projectId and operationId. Once instance is created give follow up steps on how to use the variables to bring data plane MCP server up in local and remote setup."
delay: 1s
maxDelay: 4m
multiplier: 2
maxRetries: 10
```
## Reference
| **field** | **type** | **required** | **description** |
| ----------- | :------: | :----------: | ---------------------------------------------------------------------------------------------------------------- |
| kind | string | true | Must be "cloud-sql-wait-for-operation". |
| source | string | true | The name of an `http` source to use for authentication. |
| description | string | true | A description of the tool. |
| delay | duration | false | The initial delay between polling requests (e.g., `3s`). Defaults to 3 seconds. |
| maxDelay | duration | false | The maximum delay between polling requests (e.g., `4m`). Defaults to 4 minutes. |
| multiplier | float | false | The multiplier for the polling delay. The delay is multiplied by this value after each request. Defaults to 2.0. |
| maxRetries | int | false | The maximum number of polling attempts before giving up. Defaults to 10. |

View File

@@ -0,0 +1,412 @@
---
title: "firestore-query"
type: docs
weight: 1
description: >
Query a Firestore collection with parameterizable filters and Firestore native JSON value types
aliases:
- /resources/tools/firestore-query
---
## Overview
The `firestore-query` tool allows you to query Firestore collections with dynamic, parameterizable filters that support Firestore's native JSON value types. This tool is designed for querying single collection, which is the standard pattern in Firestore. The collection path itself can be parameterized, making it flexible for various use cases. This tool is particularly useful when you need to create reusable query templates with parameters that can be substituted at runtime.
**Developer Note**: This tool serves as the general querying foundation that developers can use to create custom tools with specific query patterns.
## Key Features
- **Parameterizable Queries**: Use Go template syntax to create dynamic queries
- **Dynamic Collection Paths**: The collection path can be parameterized for flexibility
- **Native JSON Value Types**: Support for Firestore's typed values (stringValue, integerValue, doubleValue, etc.)
- **Complex Filter Logic**: Support for AND/OR logical operators in filters
- **Template Substitution**: Dynamic collection paths, filters, and ordering
- **Query Analysis**: Optional query performance analysis with explain metrics (non-parameterizable)
## Configuration
### Basic Configuration
```yaml
tools:
query_countries:
kind: firestore-query
source: my-firestore-source
description: Query countries with dynamic filters
collectionPath: "countries"
filters: |
{
"field": "continent",
"op": "==",
"value": {"stringValue": "{{.continent}}"}
}
parameters:
- name: continent
type: string
description: Continent to filter by
required: true
```
### Advanced Configuration with Complex Filters
```yaml
tools:
advanced_query:
kind: firestore-query
source: my-firestore-source
description: Advanced query with complex filters
collectionPath: "{{.collection}}"
filters: |
{
"or": [
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
{
"and": [
{"field": "priority", "op": ">", "value": {"integerValue": "{{.priority}}"}},
{"field": "area", "op": "<", "value": {"doubleValue": {{.maxArea}}}},
{"field": "active", "op": "==", "value": {"booleanValue": {{.isActive}}}}
]
}
]
}
select:
- name
- status
- priority
orderBy:
field: "{{.sortField}}"
direction: "{{.sortDirection}}"
limit: 100
analyzeQuery: true
parameters:
- name: collection
type: string
description: Collection to query
required: true
- name: status
type: string
description: Status to filter by
required: true
- name: priority
type: string
description: Minimum priority value
required: true
- name: maxArea
type: float
description: Maximum area value
required: true
- name: isActive
type: boolean
description: Filter by active status
required: true
- name: sortField
type: string
description: Field to sort by
required: false
default: "createdAt"
- name: sortDirection
type: string
description: Sort direction (ASCENDING or DESCENDING)
required: false
default: "DESCENDING"
```
## Parameters
### Configuration Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `kind` | string | Yes | Must be `firestore-query` |
| `source` | string | Yes | Name of the Firestore source to use |
| `description` | string | Yes | Description of what this tool does |
| `collectionPath` | string | Yes | Path to the collection to query (supports templates) |
| `filters` | string | No | JSON string defining query filters (supports templates) |
| `select` | array | No | Fields to select from documents(supports templates - string or array) |
| `orderBy` | object | No | Ordering configuration with `field` and `direction`(supports templates for the value of field or direction) |
| `limit` | integer | No | Maximum number of documents to return (default: 100) (supports templates) |
| `analyzeQuery` | boolean | No | Whether to analyze query performance (default: false) |
| `parameters` | array | Yes | Parameter definitions for template substitution |
### Runtime Parameters
Runtime parameters are defined in the `parameters` array and can be used in templates throughout the configuration.
## Filter Format
### Simple Filter
```json
{
"field": "age",
"op": ">",
"value": {"integerValue": "25"}
}
```
### AND Filter
```json
{
"and": [
{"field": "status", "op": "==", "value": {"stringValue": "active"}},
{"field": "age", "op": ">=", "value": {"integerValue": "18"}}
]
}
```
### OR Filter
```json
{
"or": [
{"field": "role", "op": "==", "value": {"stringValue": "admin"}},
{"field": "role", "op": "==", "value": {"stringValue": "moderator"}}
]
}
```
### Nested Filters
```json
{
"or": [
{"field": "type", "op": "==", "value": {"stringValue": "premium"}},
{
"and": [
{"field": "type", "op": "==", "value": {"stringValue": "standard"}},
{"field": "credits", "op": ">", "value": {"integerValue": "1000"}}
]
}
]
}
```
## Firestore Native Value Types
The tool supports all Firestore native JSON value types:
| Type | Format | Example |
|------|--------|---------|
| String | `{"stringValue": "text"}` | `{"stringValue": "{{.name}}"}` |
| Integer | `{"integerValue": "123"}` or `{"integerValue": 123}` | `{"integerValue": "{{.age}}"}` or `{"integerValue": {{.age}}}` |
| Double | `{"doubleValue": 45.67}` | `{"doubleValue": {{.price}}}` |
| Boolean | `{"booleanValue": true}` | `{"booleanValue": {{.active}}}` |
| Null | `{"nullValue": null}` | `{"nullValue": null}` |
| Timestamp | `{"timestampValue": "RFC3339"}` | `{"timestampValue": "{{.date}}"}` |
| GeoPoint | `{"geoPointValue": {"latitude": 0, "longitude": 0}}` | See below |
| Array | `{"arrayValue": {"values": [...]}}` | See below |
| Map | `{"mapValue": {"fields": {...}}}` | See below |
### Complex Type Examples
**GeoPoint:**
```json
{
"field": "location",
"op": "==",
"value": {
"geoPointValue": {
"latitude": 37.7749,
"longitude": -122.4194
}
}
}
```
**Array:**
```json
{
"field": "tags",
"op": "array-contains",
"value": {"stringValue": "{{.tag}}"}
}
```
## Supported Operators
- `<` - Less than
- `<=` - Less than or equal
- `>` - Greater than
- `>=` - Greater than or equal
- `==` - Equal
- `!=` - Not equal
- `array-contains` - Array contains value
- `array-contains-any` - Array contains any of the values
- `in` - Value is in array
- `not-in` - Value is not in array
## Examples
### Example 1: Query with Dynamic Collection Path
```yaml
tools:
user_documents:
kind: firestore-query
source: my-firestore
description: Query user-specific documents
collectionPath: "users/{{.userId}}/documents"
filters: |
{
"field": "type",
"op": "==",
"value": {"stringValue": "{{.docType}}"}
}
parameters:
- name: userId
type: string
description: User ID
required: true
- name: docType
type: string
description: Document type to filter
required: true
```
### Example 2: Complex Geographic Query
```yaml
tools:
location_search:
kind: firestore-query
source: my-firestore
description: Search locations by area and population
collectionPath: "cities"
filters: |
{
"and": [
{"field": "country", "op": "==", "value": {"stringValue": "{{.country}}"}},
{"field": "population", "op": ">", "value": {"integerValue": "{{.minPopulation}}"}},
{"field": "area", "op": "<", "value": {"doubleValue": {{.maxArea}}}}
]
}
orderBy:
field: "population"
direction: "DESCENDING"
limit: 50
parameters:
- name: country
type: string
description: Country code
required: true
- name: minPopulation
type: string
description: Minimum population (as string for large numbers)
required: true
- name: maxArea
type: float
description: Maximum area in square kilometers
required: true
```
### Example 3: Time-based Query with Analysis
```yaml
tools:
activity_log:
kind: firestore-query
source: my-firestore
description: Query activity logs within time range
collectionPath: "logs"
filters: |
{
"and": [
{"field": "timestamp", "op": ">=", "value": {"timestampValue": "{{.startTime}}"}},
{"field": "timestamp", "op": "<=", "value": {"timestampValue": "{{.endTime}}"}},
{"field": "severity", "op": "in", "value": {"arrayValue": {"values": [
{"stringValue": "ERROR"},
{"stringValue": "CRITICAL"}
]}}}
]
}
select:
- timestamp
- message
- severity
- userId
orderBy:
field: "timestamp"
direction: "DESCENDING"
analyzeQuery: true
parameters:
- name: startTime
type: string
description: Start time in RFC3339 format
required: true
- name: endTime
type: string
description: End time in RFC3339 format
required: true
```
## Usage
### Invoking the Tool
```bash
# Using curl
curl -X POST http://localhost:5000/api/tool/your-tool-name/invoke \
-H "Content-Type: application/json" \
-d '{
"continent": "Europe",
"minPopulation": "1000000",
"maxArea": 500000.5,
"isActive": true
}'
```
### Response Format
**Without analyzeQuery:**
```json
[
{
"id": "doc1",
"path": "countries/doc1",
"data": {
"name": "France",
"continent": "Europe",
"population": 67000000,
"area": 551695
},
"createTime": "2024-01-01T00:00:00Z",
"updateTime": "2024-01-15T10:30:00Z"
}
]
```
**With analyzeQuery:**
```json
{
"documents": [...],
"explainMetrics": {
"planSummary": {
"indexesUsed": [...]
},
"executionStats": {
"resultsReturned": 10,
"executionDuration": "15ms",
"readOperations": 10
}
}
}
```
## Best Practices
1. **Use Typed Values**: Always use Firestore's native JSON value types for proper type handling
2. **String Numbers for Large Integers**: Use string representation for large integers to avoid precision loss
3. **Template Security**: Validate all template parameters to prevent injection attacks
4. **Index Optimization**: Use `analyzeQuery` to identify missing indexes
5. **Limit Results**: Always set a reasonable `limit` to prevent excessive data retrieval
6. **Field Selection**: Use `select` to retrieve only necessary fields
## Technical Notes
- Queries operate on a single collection (the standard Firestore pattern)
- Maximum of 100 filters per query (configurable)
- Template parameters must be properly escaped in JSON contexts
- Complex nested queries may require composite indexes
## See Also
- [firestore-query-collection](firestore-query-collection.md) - Non-parameterizable query tool
- [Firestore Source Configuration](../../sources/firestore.md)
- [Firestore Query Documentation](https://firebase.google.com/docs/firestore/query-data/queries)

View File

@@ -0,0 +1,101 @@
---
title: "yugabytedb-sql"
type: docs
weight: 1
description: >
A "yugabytedb-sql" tool executes a pre-defined SQL statement against a YugabyteDB
database.
---
## About
A `yugabytedb-sql` tool executes a pre-defined SQL statement against a YugabyteDB
database.
The specified SQL statement is executed as a prepared statement,
and specified parameters will inserted according to their position: e.g. `1`
will be the first parameter specified, `$@` will be the second parameter, and so
on. If template parameters are included, they will be resolved before execution
of the prepared statement.
## Example
> **Note:** This tool uses parameterized queries to prevent SQL injections.
> Query parameters can be used as substitutes for arbitrary expressions.
> Parameters cannot be used as substitutes for identifiers, column names, table
> names, or other parts of the query.
```yaml
tools:
search_flights_by_number:
kind: yugabytedb-sql
source: my-yb-instance
statement: |
SELECT * FROM flights
WHERE airline = $1
AND flight_number = $2
LIMIT 10
description: |
Use this tool to get information for a specific flight.
Takes an airline code and flight number and returns info on the flight.
Do NOT use this tool with a flight id. Do NOT guess an airline code or flight number.
A airline code is a code for an airline service consisting of two-character
airline designator and followed by flight number, which is 1 to 4 digit number.
For example, if given CY 0123, the airline is "CY", and flight_number is "123".
Another example for this is DL 1234, the airline is "DL", and flight_number is "1234".
If the tool returns more than one option choose the date closes to today.
Example:
{{
"airline": "CY",
"flight_number": "888",
}}
Example:
{{
"airline": "DL",
"flight_number": "1234",
}}
parameters:
- name: airline
type: string
description: Airline unique 2 letter identifier
- name: flight_number
type: string
description: 1 to 4 digit number
```
### Example with Template Parameters
> **Note:** This tool allows direct modifications to the SQL statement, including identifiers, column names,
> and table names. **This makes it more vulnerable to SQL injections**. Using basic parameters
> only (see above) is recommended for performance and safety reasons. For more details, please
> check [templateParameters](_index#template-parameters).
```yaml
tools:
list_table:
kind: yugabytedb-sql
source: my-yb-instance
statement: |
SELECT * FROM {{.tableName}}
description: |
Use this tool to list all information from a specific table.
Example:
{{
"tableName": "flights",
}}
templateParameters:
- name: tableName
type: string
description: Table to select from
```
## Reference
| **field** | **type** | **required** | **description** |
|---------------------|:---------------------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------------------------------------------------|
| kind | string | true | Must be "yugabytedb-sql". |
| source | string | true | Name of the source the SQL should execute on. |
| description | string | true | Description of the tool that is passed to the LLM. |
| statement | string | true | SQL statement to execute on. |
| parameters | [parameters](_index#specifying-parameters) | false | List of [parameters](_index#specifying-parameters) that will be inserted into the SQL statement. |
| templateParameters | [templateParameters](_index#template-parameters) | false | List of [templateParameters](_index#template-parameters) that will be inserted into the SQL statement before executing prepared statement. |

1
go.mod
View File

@@ -39,6 +39,7 @@ require (
github.com/thlib/go-timezone-local v0.0.7
github.com/trinodb/trino-go-client v0.328.0
github.com/valkey-io/valkey-go v1.0.64
github.com/yugabyte/pgx/v5 v5.5.3-yb-5
go.mongodb.org/mongo-driver v1.17.4
go.opentelemetry.io/contrib/propagators/autoprop v0.62.0
go.opentelemetry.io/otel v1.37.0

2
go.sum
View File

@@ -1270,6 +1270,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yugabyte/pgx/v5 v5.5.3-yb-5 h1:MV66FoH4HFsA9IC+h1hRY/+9Rmo040zVyZovOX7zpuk=
github.com/yugabyte/pgx/v5 v5.5.3-yb-5/go.mod h1:2SxizGfDY7UDCRTtbI/xd98C/oGN7S/3YoGF8l9gx/c=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View File

@@ -1,7 +1,21 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
sources:
bigquery-source:
kind: "bigquery"
project: ${BIGQUERY_PROJECT}
location: ${BIGQUERY_LOCATION:}
tools:
ask_data_insights:

View File

@@ -1,3 +1,16 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
sources:
cloudsql-mssql-source:
kind: cloud-sql-mssql
@@ -8,6 +21,7 @@ sources:
ipAddress: ${CLOUD_SQL_MSSQL_IP_ADDRESS}
user: ${CLOUD_SQL_MSSQL_USER}
password: ${CLOUD_SQL_MSSQL_PASSWORD}
ipType: ${CLOUD_SQL_MSSQL_IP_TYPE:public}
tools:
execute_sql:
kind: mssql-execute-sql

View File

@@ -1,3 +1,16 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
sources:
cloudsql-pg-source:
kind: cloud-sql-postgres
@@ -5,8 +18,9 @@ sources:
region: ${CLOUD_SQL_POSTGRES_REGION}
instance: ${CLOUD_SQL_POSTGRES_INSTANCE}
database: ${CLOUD_SQL_POSTGRES_DATABASE}
user: ${CLOUD_SQL_POSTGRES_USER}
password: ${CLOUD_SQL_POSTGRES_PASSWORD}
user: ${CLOUD_SQL_POSTGRES_USER:}
password: ${CLOUD_SQL_POSTGRES_PASSWORD:}
ipType: ${CLOUD_SQL_POSTGRES_IP_TYPE:public}
tools:
execute_sql:
@@ -91,7 +105,7 @@ tools:
'constraints', COALESCE((SELECT json_agg(json_build_object('constraint_name',cons.constraint_name,'constraint_type',cons.constraint_type,'constraint_definition',cons.constraint_definition,'constraint_columns',cons.constraint_columns,'foreign_key_referenced_table',cons.foreign_key_referenced_table,'foreign_key_referenced_columns',cons.foreign_key_referenced_columns)) FROM constraints_info cons WHERE cons.table_oid = ti.table_oid), '[]'::json),
'indexes', COALESCE((SELECT json_agg(json_build_object('index_name',ii.index_name,'index_definition',ii.index_definition,'is_unique',ii.is_unique,'is_primary',ii.is_primary,'index_method',ii.index_method,'index_columns',ii.index_columns)) FROM indexes_info ii WHERE ii.table_oid = ti.table_oid), '[]'::json),
'triggers', COALESCE((SELECT json_agg(json_build_object('trigger_name',tri.trigger_name,'trigger_definition',tri.trigger_definition,'trigger_enabled_state',tri.trigger_enabled_state)) FROM triggers_info tri WHERE tri.table_oid = ti.table_oid), '[]'::json)
)
)
END AS object_details
FROM table_info ti ORDER BY ti.schema_name, ti.table_name;
parameters:

View File

@@ -1,8 +1,21 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
sources:
firestore-source:
kind: firestore
project: ${FIRESTORE_PROJECT}
database: ${FIRESTORE_DATABASE}
database: ${FIRESTORE_DATABASE:}
tools:
firestore-get-documents:
@@ -13,26 +26,26 @@ tools:
kind: firestore-add-documents
source: firestore-source
description: |
Adds a new document to a Firestore collection. Please follow the best practices :
1. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
2. Integer values can be strings in the documentData: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
3. Use returnData sparingly: Only set to true when you need to verify the exact data that was written
4. Validate data before sending: Ensure your data matches Firestore's native JSON format
5. Handle timestamps properly: Use RFC3339 format for timestamp strings
6. Base64 encode binary data: Binary data must be base64 encoded in the bytesValue field
7. Consider security rules: Ensure your Firestore security rules allow document creation in the target collection
Adds a new document to a Firestore collection. Please follow the best practices :
1. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
2. Integer values can be strings in the documentData: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
3. Use returnData sparingly: Only set to true when you need to verify the exact data that was written
4. Validate data before sending: Ensure your data matches Firestore's native JSON format
5. Handle timestamps properly: Use RFC3339 format for timestamp strings
6. Base64 encode binary data: Binary data must be base64 encoded in the bytesValue field
7. Consider security rules: Ensure your Firestore security rules allow document creation in the target collection
firestore-update-document:
kind: firestore-update-document
source: firestore-source
description: |
Updates an existing document in Firestore. Supports both full document updates and selective field updates using an update mask. Please follow the best practices:
1. Use update masks for precision: When you only need to update specific fields, use the updateMask parameter to avoid unintended changes
2. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
3. Delete fields using update mask: To delete fields, include them in the updateMask but omit them from documentData
4. Integer values can be strings: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
5. Use returnData sparingly: Only set to true when you need to verify the exact data after the update
6. Handle timestamps properly: Use RFC3339 format for timestamp strings
7. Consider security rules: Ensure your Firestore security rules allow document updates
Updates an existing document in Firestore. Supports both full document updates and selective field updates using an update mask. Please follow the best practices:
1. Use update masks for precision: When you only need to update specific fields, use the updateMask parameter to avoid unintended changes
2. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
3. Delete fields using update mask: To delete fields, include them in the updateMask but omit them from documentData
4. Integer values can be strings: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
5. Use returnData sparingly: Only set to true when you need to verify the exact data after the update
6. Handle timestamps properly: Use RFC3339 format for timestamp strings
7. Consider security rules: Ensure your Firestore security rules allow document updates
firestore-list-collections:
kind: firestore-list-collections
source: firestore-source
@@ -44,8 +57,8 @@ tools:
firestore-query-collection:
kind: firestore-query-collection
source: firestore-source
description: |
Retrieves one or more Firestore documents from a collection in a database in the current project by a collection with a full document path.
description: |
Retrieves one or more Firestore documents from a collection in a database in the current project by a collection with a full document path.
Use this if you know the exact path of a collection and the filtering clause you would like for the document.
firestore-get-rules:
kind: firestore-get-rules

View File

@@ -1,3 +1,16 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
sources:
postgresql-source:
kind: postgres
@@ -6,6 +19,7 @@ sources:
database: ${POSTGRES_DATABASE}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
queryParams: ${POSTGRES_QUERY_PARAMS:}
tools:
execute_sql:
@@ -90,7 +104,7 @@ tools:
'constraints', COALESCE((SELECT json_agg(json_build_object('constraint_name',cons.constraint_name,'constraint_type',cons.constraint_type,'constraint_definition',cons.constraint_definition,'constraint_columns',cons.constraint_columns,'foreign_key_referenced_table',cons.foreign_key_referenced_table,'foreign_key_referenced_columns',cons.foreign_key_referenced_columns)) FROM constraints_info cons WHERE cons.table_oid = ti.table_oid), '[]'::json),
'indexes', COALESCE((SELECT json_agg(json_build_object('index_name',ii.index_name,'index_definition',ii.index_definition,'is_unique',ii.is_unique,'is_primary',ii.is_primary,'index_method',ii.index_method,'index_columns',ii.index_columns)) FROM indexes_info ii WHERE ii.table_oid = ti.table_oid), '[]'::json),
'triggers', COALESCE((SELECT json_agg(json_build_object('trigger_name',tri.trigger_name,'trigger_definition',tri.trigger_definition,'trigger_enabled_state',tri.trigger_enabled_state)) FROM triggers_info tri WHERE tri.table_oid = ti.table_oid), '[]'::json)
)
)
END AS object_details
FROM table_info ti ORDER BY ti.schema_name, ti.table_name;
parameters:

View File

@@ -21,7 +21,6 @@ import (
bigqueryapi "cloud.google.com/go/bigquery"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
@@ -35,7 +34,7 @@ const SourceKind string = "bigquery"
// validate interface
var _ sources.SourceConfig = Config{}
type BigqueryClientCreator func(tokenString tools.AccessToken, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error)
type BigqueryClientCreator func(tokenString string, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error)
func init() {
if !sources.Register(SourceKind, newConfig) {
@@ -199,7 +198,7 @@ func initBigQueryConnectionWithOAuthToken(
location string,
name string,
userAgent string,
tokenString tools.AccessToken,
tokenString string,
wantRestService bool,
) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) {
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name)
@@ -238,13 +237,13 @@ func newBigQueryClientCreator(
project string,
location string,
name string,
) (func(tools.AccessToken, bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error), error) {
) (func(string, bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error), error) {
userAgent, err := util.UserAgentFromContext(ctx)
if err != nil {
return nil, err
}
return func(tokenString tools.AccessToken, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) {
return func(tokenString string, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) {
return initBigQueryConnectionWithOAuthToken(ctx, tracer, project, location, name, userAgent, tokenString, wantRestService)
}, nil
}

View File

@@ -0,0 +1,127 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yugabytedb
import (
"context"
"fmt"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/yugabyte/pgx/v5/pgxpool"
"go.opentelemetry.io/otel/trace"
)
const SourceKind string = "yugabytedb"
// validate interface
var _ sources.SourceConfig = Config{}
func init() {
if !sources.Register(SourceKind, newConfig) {
panic(fmt.Sprintf("source kind %q already registered", SourceKind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Host string `yaml:"host" validate:"required"`
Port string `yaml:"port" validate:"required"`
User string `yaml:"user" validate:"required"`
Password string `yaml:"password" validate:"required"`
Database string `yaml:"database" validate:"required"`
LoadBalance string `yaml:"loadBalance"`
TopologyKeys string `yaml:"topologyKeys"`
YBServersRefreshInterval string `yaml:"ybServersRefreshInterval"`
FallBackToTopologyKeysOnly string `yaml:"fallbackToTopologyKeysOnly"`
FailedHostReconnectDelaySeconds string `yaml:"failedHostReconnectDelaySecs"`
}
func (r Config) SourceConfigKind() string {
return SourceKind
}
func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
pool, err := initYugabyteDBConnectionPool(ctx, tracer, r.Name, r.Host, r.Port, r.User, r.Password, r.Database, r.LoadBalance, r.TopologyKeys, r.YBServersRefreshInterval, r.FallBackToTopologyKeysOnly, r.FailedHostReconnectDelaySeconds)
if err != nil {
return nil, fmt.Errorf("unable to create pool: %w", err)
}
err = pool.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("unable to connect successfully: %w", err)
}
s := &Source{
Name: r.Name,
Kind: SourceKind,
Pool: pool,
}
return s, nil
}
var _ sources.Source = &Source{}
type Source struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
Pool *pgxpool.Pool
}
func (s *Source) SourceKind() string {
return SourceKind
}
func (s *Source) YugabyteDBPool() *pgxpool.Pool {
return s.Pool
}
func initYugabyteDBConnectionPool(ctx context.Context, tracer trace.Tracer, name, host, port, user, pass, dbname, loadBalance, topologyKeys, refreshInterval, explicitFallback, failedHostTTL string) (*pgxpool.Pool, error) {
//nolint:all // Reassigned ctx
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name)
defer span.End()
// urlExample := "postgres://username:password@localhost:5433/database_name"
i := fmt.Sprintf("postgres://%s:%s@%s:%s/%s", user, pass, host, port, dbname)
if loadBalance == "true" {
i = fmt.Sprintf("%s?load_balance=%s", i, loadBalance)
if topologyKeys != "" {
i = fmt.Sprintf("%s&topology_keys=%s", i, topologyKeys)
if explicitFallback == "true" {
i = fmt.Sprintf("%s&fallback_to_topology_keys_only=%s", i, explicitFallback)
}
}
if refreshInterval != "" {
i = fmt.Sprintf("%s&yb_servers_refresh_interval=%s", i, refreshInterval)
}
if failedHostTTL != "" {
i = fmt.Sprintf("%s&failed_host_reconnect_delay_secs=%s", i, failedHostTTL)
}
}
pool, err := pgxpool.New(ctx, i)
if err != nil {
return nil, fmt.Errorf("unable to create connection pool: %w", err)
}
return pool, nil
}

View File

@@ -0,0 +1,299 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yugabytedb_test
import (
"testing"
"strings"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/sources/yugabytedb"
"github.com/googleapis/genai-toolbox/internal/testutils"
)
// Basic config parse
func TestParseFromYamlYugabyteDB(t *testing.T) {
tcs := []struct {
desc string
in string
want server.SourceConfigs
}{
{
desc: "only required fields",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
},
},
},
{
desc: "with loadBalance only",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
loadBalance: true
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
LoadBalance: "true",
},
},
},
{
desc: "loadBalance with topologyKeys",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
loadBalance: true
topologyKeys: zone1,zone2
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
LoadBalance: "true",
TopologyKeys: "zone1,zone2",
},
},
},
{
desc: "with fallback only",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
loadBalance: true
topologyKeys: zone1
fallbackToTopologyKeysOnly: true
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
LoadBalance: "true",
TopologyKeys: "zone1",
FallBackToTopologyKeysOnly: "true",
},
},
},
{
desc: "with refresh interval and reconnect delay",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
loadBalance: true
ybServersRefreshInterval: 20
failedHostReconnectDelaySecs: 5
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
LoadBalance: "true",
YBServersRefreshInterval: "20",
FailedHostReconnectDelaySeconds: "5",
},
},
},
{
desc: "all fields set",
in: `
sources:
my-yb-instance:
kind: yugabytedb
name: my-yb-instance
host: yb-host
port: yb-port
user: yb_user
password: yb_pass
database: yb_db
loadBalance: true
topologyKeys: zone1,zone2
fallbackToTopologyKeysOnly: true
ybServersRefreshInterval: 30
failedHostReconnectDelaySecs: 10
`,
want: server.SourceConfigs{
"my-yb-instance": yugabytedb.Config{
Name: "my-yb-instance",
Kind: "yugabytedb",
Host: "yb-host",
Port: "yb-port",
User: "yb_user",
Password: "yb_pass",
Database: "yb_db",
LoadBalance: "true",
TopologyKeys: "zone1,zone2",
FallBackToTopologyKeysOnly: "true",
YBServersRefreshInterval: "30",
FailedHostReconnectDelaySeconds: "10",
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Sources server.SourceConfigs `yaml:"sources"`
}{}
err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if !cmp.Equal(tc.want, got.Sources) {
t.Fatalf("incorrect parse (-want +got):\n%s", cmp.Diff(tc.want, got.Sources))
}
})
}
}
func TestFailParseFromYamlYugabyteDB(t *testing.T) {
tcs := []struct {
desc string
in string
err string
}{
{
desc: "extra field",
in: `
sources:
my-yb-source:
kind: yugabytedb
name: my-yb-source
host: yb-host
port: yb-port
database: yb_db
user: yb_user
password: yb_pass
foo: bar
`,
err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": [2:1] unknown field \"foo\"",
},
{
desc: "missing required field (password)",
in: `
sources:
my-yb-source:
kind: yugabytedb
name: my-yb-source
host: yb-host
port: yb-port
database: yb_db
user: yb_user
`,
err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Password' Error:Field validation for 'Password' failed on the 'required' tag",
},
{
desc: "missing required field (host)",
in: `
sources:
my-yb-source:
kind: yugabytedb
name: my-yb-source
port: yb-port
database: yb_db
user: yb_user
password: yb_pass
`,
err: "unable to parse source \"my-yb-source\" as \"yugabytedb\": Key: 'Config.Host' Error:Field validation for 'Host' failed on the 'required' tag",
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Sources server.SourceConfigs `yaml:"sources"`
}{}
err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got)
if err == nil {
t.Fatalf("expected parsing to fail")
}
errStr := err.Error()
if !strings.Contains(errStr, tc.err) {
t.Fatalf("unexpected error:\nGot: %q\nWant: %q", errStr, tc.err)
}
})
}
}

View File

@@ -183,14 +183,18 @@ type Tool struct {
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
var tokenStr string
var err error
// Get credentials for the API call
if t.UseClientOAuth {
// Use client-side access token
if accessToken == "" {
return nil, fmt.Errorf("tool is configured for client OAuth but no token was provided in the request header")
return nil, fmt.Errorf("tool is configured for client OAuth but no token was provided in the request header: %w", tools.ErrUnauthorized)
}
tokenStr, err = accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
tokenStr = string(accessToken)
} else {
// Use ADC
if t.TokenSource == nil {

View File

@@ -150,7 +150,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
var err error
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, restService, err = t.ClientCreator(accessToken, true)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, restService, err = t.ClientCreator(tokenStr, true)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -199,7 +199,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, _, err = t.ClientCreator(accessToken, false)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, _, err = t.ClientCreator(tokenStr, false)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -142,7 +142,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, _, err = t.ClientCreator(accessToken, false)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, _, err = t.ClientCreator(tokenStr, false)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -149,7 +149,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
var err error
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, _, err = t.ClientCreator(accessToken, false)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, _, err = t.ClientCreator(tokenStr, false)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -133,10 +133,13 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
}
bqClient := t.Client
var err error
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, _, err = t.ClientCreator(accessToken, false)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, _, err = t.ClientCreator(tokenStr, false)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -139,10 +139,13 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
}
bqClient := t.Client
var err error
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, _, err = t.ClientCreator(accessToken, false)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, _, err = t.ClientCreator(tokenStr, false)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -222,7 +222,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
// Initialize new client if using user OAuth token
if t.UseClientOAuth {
bqClient, restService, err = t.ClientCreator(accessToken, true)
tokenStr, err := accessToken.ParseBearerToken()
if err != nil {
return nil, fmt.Errorf("error parsing access token: %w", err)
}
bqClient, restService, err = t.ClientCreator(tokenStr, true)
if err != nil {
return nil, fmt.Errorf("error creating client from OAuth access token: %w", err)
}

View File

@@ -0,0 +1,440 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloudsqlwaitforoperation
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"text/template"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
httpsrc "github.com/googleapis/genai-toolbox/internal/sources/http"
"github.com/googleapis/genai-toolbox/internal/tools"
"golang.org/x/oauth2/google"
)
const kind string = "cloud-sql-wait-for-operation"
var cloudSQLConnectionMessageTemplate = `Your Cloud SQL resource is ready.
To connect, please configure your environment. The method depends on how you are running the toolbox:
**If running locally via stdio:**
Update the MCP server configuration with the following environment variables:
` + "```json" + `
{
"mcpServers": {
"cloud-sql-{{.DBType}}": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt","cloud-sql-{{.DBType}}","--stdio"],
"env": {
"CLOUD_SQL_{{.DBTypeUpper}}_PROJECT": "{{.Project}}",
"CLOUD_SQL_{{.DBTypeUpper}}_REGION": "{{.Region}}",
"CLOUD_SQL_{{.DBTypeUpper}}_INSTANCE": "{{.Instance}}",
"CLOUD_SQL_{{.DBTypeUpper}}_DATABASE": "{{.Database}}",
"CLOUD_SQL_{{.DBTypeUpper}}_USER": "<your-user>",
"CLOUD_SQL_{{.DBTypeUpper}}_PASSWORD": "<your-password>"
}
}
}
}
` + "```" + `
**If running remotely:**
For remote deployments, you will need to set the following environment variables in your deployment configuration:
` + "```" + `
CLOUD_SQL_{{.DBTypeUpper}}_PROJECT={{.Project}}
CLOUD_SQL_{{.DBTypeUpper}}_REGION={{.Region}}
CLOUD_SQL_{{.DBTypeUpper}}_INSTANCE={{.Instance}}
CLOUD_SQL_{{.DBTypeUpper}}_DATABASE={{.Database}}
CLOUD_SQL_{{.DBTypeUpper}}_USER=<your-user>
CLOUD_SQL_{{.DBTypeUpper}}_PASSWORD=<your-password>
` + "```" + `
Please refer to the official documentation for guidance on deploying the toolbox:
- Deploying the Toolbox: https://googleapis.github.io/genai-toolbox/how-to/deploy_toolbox/
- Deploying on GKE: https://googleapis.github.io/genai-toolbox/how-to/deploy_gke/
`
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
// Config defines the configuration for the wait-for-operation tool.
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
BaseURL string `yaml:"baseURL"`
// Polling configuration
Delay string `yaml:"delay"`
MaxDelay string `yaml:"maxDelay"`
Multiplier float64 `yaml:"multiplier"`
MaxRetries int `yaml:"maxRetries"`
}
// validate interface
var _ tools.ToolConfig = Config{}
// ToolConfigKind returns the kind of the tool.
func (cfg Config) ToolConfigKind() string {
return kind
}
// Initialize initializes the tool from the configuration.
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
s, ok := rawS.(*httpsrc.Source)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `http`", kind)
}
if s.BaseURL != "https://sqladmin.googleapis.com" && !strings.HasPrefix(s.BaseURL, "http://127.0.0.1") {
return nil, fmt.Errorf("invalid source for %q tool: baseUrl must be `https://sqladmin.googleapis.com`", kind)
}
allParameters := tools.Parameters{
tools.NewStringParameter("project", "The project ID"),
tools.NewStringParameter("operation", "The operation ID"),
}
paramManifest := allParameters.Manifest()
inputSchema := allParameters.McpManifest()
inputSchema.Required = []string{"project", "operation"}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: inputSchema,
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://sqladmin.googleapis.com"
}
var delay time.Duration
if cfg.Delay == "" {
delay = 3 * time.Second
} else {
var err error
delay, err = time.ParseDuration(cfg.Delay)
if err != nil {
return nil, fmt.Errorf("invalid value for delay: %w", err)
}
}
var maxDelay time.Duration
if cfg.MaxDelay == "" {
maxDelay = 4 * time.Minute
} else {
var err error
maxDelay, err = time.ParseDuration(cfg.MaxDelay)
if err != nil {
return nil, fmt.Errorf("invalid value for maxDelay: %w", err)
}
}
multiplier := cfg.Multiplier
if multiplier == 0 {
multiplier = 2.0
}
maxRetries := cfg.MaxRetries
if maxRetries == 0 {
maxRetries = 10
}
return Tool{
Name: cfg.Name,
Kind: kind,
BaseURL: baseURL,
AuthRequired: cfg.AuthRequired,
Client: s.Client,
AllParams: allParameters,
manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
Delay: delay,
MaxDelay: maxDelay,
Multiplier: multiplier,
MaxRetries: maxRetries,
}, nil
}
// Tool represents the wait-for-operation tool.
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
Description string `yaml:"description"`
AuthRequired []string `yaml:"authRequired"`
BaseURL string `yaml:"baseURL"`
AllParams tools.Parameters `yaml:"allParams"`
// Polling configuration
Delay time.Duration
MaxDelay time.Duration
Multiplier float64
MaxRetries int
Client *http.Client
manifest tools.Manifest
mcpManifest tools.McpManifest
}
// Invoke executes the tool's logic.
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
paramsMap := params.AsMap()
project, ok := paramsMap["project"].(string)
if !ok {
return nil, fmt.Errorf("missing 'project' parameter")
}
operationID, ok := paramsMap["operation"].(string)
if !ok {
return nil, fmt.Errorf("missing 'operation' parameter")
}
urlString := fmt.Sprintf("%s/v1/projects/%s/operations/%s", t.BaseURL, project, operationID)
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
delay := t.Delay
maxDelay := t.MaxDelay
multiplier := t.Multiplier
maxRetries := t.MaxRetries
retries := 0
for retries < maxRetries {
select {
case <-ctx.Done():
return nil, fmt.Errorf("timed out waiting for operation: %w", ctx.Err())
default:
}
req, _ := http.NewRequest(http.MethodGet, urlString, nil)
tokenSource, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/sqlservice.admin")
if err != nil {
return nil, fmt.Errorf("error creating token source: %w", err)
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("error retrieving token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := t.Client.Do(req)
if err != nil {
fmt.Printf("error making HTTP request during polling: %s, retrying in %v\n", err, delay)
} else {
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("error reading response body during polling: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code during polling: %d, response body: %s", resp.StatusCode, string(body))
}
var data map[string]any
if err := json.Unmarshal(body, &data); err == nil {
if val, ok := data["status"]; ok {
if fmt.Sprintf("%v", val) == "DONE" {
if _, ok := data["error"]; ok {
return nil, fmt.Errorf("operation finished with error: %s", string(body))
}
if msg, ok := t.generateCloudSQLConnectionMessage(data); ok {
return msg, nil
}
return string(body), nil
}
}
}
fmt.Printf("Operation not complete, retrying in %v\n", delay)
}
time.Sleep(delay)
delay = time.Duration(float64(delay) * multiplier)
if delay > maxDelay {
delay = maxDelay
}
retries++
}
return nil, fmt.Errorf("exceeded max retries waiting for operation")
}
// ParseParams parses the parameters for the tool.
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.AllParams, data, claims)
}
// Manifest returns the tool's manifest.
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
// McpManifest returns the tool's MCP manifest.
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
// Authorized checks if the tool is authorized.
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return true
}
func (t Tool) RequiresClientAuthorization() bool {
return false
}
func (t Tool) generateCloudSQLConnectionMessage(opResponse map[string]any) (string, bool) {
operationType, ok := opResponse["operationType"].(string)
if !ok || operationType != "CREATE_DATABASE" {
return "", false
}
targetLink, ok := opResponse["targetLink"].(string)
if !ok {
return "", false
}
r := regexp.MustCompile(`/projects/([^/]+)/instances/([^/]+)/databases/([^/]+)`)
matches := r.FindStringSubmatch(targetLink)
if len(matches) < 4 {
return "", false
}
project := matches[1]
instance := matches[2]
database := matches[3]
instanceData, err := t.fetchInstanceData(context.Background(), project, instance)
if err != nil {
fmt.Printf("error fetching instance data: %v\n", err)
return "", false
}
region, ok := instanceData["region"].(string)
if !ok {
return "", false
}
databaseVersion, ok := instanceData["databaseVersion"].(string)
if !ok {
return "", false
}
var dbType string
if strings.Contains(databaseVersion, "POSTGRES") {
dbType = "postgres"
} else if strings.Contains(databaseVersion, "MYSQL") {
dbType = "mysql"
} else if strings.Contains(databaseVersion, "SQLSERVER") {
dbType = "mssql"
} else {
return "", false
}
tmpl, err := template.New("cloud-sql-connection").Parse(cloudSQLConnectionMessageTemplate)
if err != nil {
return fmt.Sprintf("template parsing error: %v", err), false
}
data := struct {
Project string
Region string
Instance string
DBType string
DBTypeUpper string
Database string
}{
Project: project,
Region: region,
Instance: instance,
DBType: dbType,
DBTypeUpper: strings.ToUpper(dbType),
Database: database,
}
var b strings.Builder
if err := tmpl.Execute(&b, data); err != nil {
return fmt.Sprintf("template execution error: %v", err), false
}
return b.String(), true
}
func (t Tool) fetchInstanceData(ctx context.Context, project, instance string) (map[string]any, error) {
urlString := fmt.Sprintf("%s/v1/projects/%s/instances/%s", t.BaseURL, project, instance)
req, _ := http.NewRequest(http.MethodGet, urlString, nil)
tokenSource, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/sqlservice.admin")
if err != nil {
return nil, fmt.Errorf("error creating token source: %w", err)
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("error retrieving token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := t.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code fetching instance data: %d, response body: %s", resp.StatusCode, string(body))
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
return data, nil
}

View File

@@ -0,0 +1,80 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloudsqlwaitforoperation_test
import (
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
cloudsqlwaitforoperation "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlwaitforoperation"
)
func TestParseFromYaml(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
tools:
wait-for-thing:
kind: cloud-sql-wait-for-operation
source: some-source
description: some description
delay: 1s
maxDelay: 5s
multiplier: 1.5
maxRetries: 5
`,
want: server.ToolConfigs{
"wait-for-thing": cloudsqlwaitforoperation.Config{
Name: "wait-for-thing",
Kind: "cloud-sql-wait-for-operation",
Source: "some-source",
Description: "some description",
AuthRequired: []string{},
Delay: "1s",
MaxDelay: "5s",
Multiplier: 1.5,
MaxRetries: 5,
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}

View File

@@ -87,18 +87,30 @@ func convertParamToJSON(param any) (string, error) {
// PopulateTemplateWithJSON populate a Go template with a custom `json` array formatter
func PopulateTemplateWithJSON(templateName, templateString string, data map[string]any) (string, error) {
funcMap := template.FuncMap{
return PopulateTemplateWithFunc(templateName, templateString, data, template.FuncMap{
"json": convertParamToJSON,
})
}
// PopulateTemplate populate a Go template with no custom formatters
func PopulateTemplate(templateName, templateString string, data map[string]any) (string, error) {
return PopulateTemplateWithFunc(templateName, templateString, data, nil)
}
// PopulateTemplateWithFunc populate a Go template with provided functions
func PopulateTemplateWithFunc(templateName, templateString string, data map[string]any, funcMap template.FuncMap) (string, error) {
tmpl := template.New(templateName)
if funcMap != nil {
tmpl = tmpl.Funcs(funcMap)
}
tmpl, err := template.New(templateName).Funcs(funcMap).Parse(templateString)
parsedTmpl, err := tmpl.Parse(templateString)
if err != nil {
return "", fmt.Errorf("error parsing template '%s': %w", templateName, err)
}
var result bytes.Buffer
err = tmpl.Execute(&result, data)
if err != nil {
if err := parsedTmpl.Execute(&result, data); err != nil {
return "", fmt.Errorf("error executing template '%s': %w", templateName, err)
}
return result.String(), nil

View File

@@ -0,0 +1,297 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tools_test
import (
"strings"
"testing"
"text/template"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/tools"
)
func TestPopulateTemplate(t *testing.T) {
tcs := []struct {
name string
templateName string
templateString string
data map[string]any
want string
wantErr bool
}{
{
name: "simple string substitution",
templateName: "test",
templateString: "Hello {{.name}}!",
data: map[string]any{"name": "World"},
want: "Hello World!",
wantErr: false,
},
{
name: "multiple substitutions",
templateName: "test",
templateString: "{{.greeting}} {{.name}}, you are {{.age}} years old",
data: map[string]any{"greeting": "Hello", "name": "Alice", "age": 30},
want: "Hello Alice, you are 30 years old",
wantErr: false,
},
{
name: "empty template",
templateName: "test",
templateString: "",
data: map[string]any{},
want: "",
wantErr: false,
},
{
name: "no substitutions",
templateName: "test",
templateString: "Plain text without templates",
data: map[string]any{},
want: "Plain text without templates",
wantErr: false,
},
{
name: "invalid template syntax",
templateName: "test",
templateString: "{{.name",
data: map[string]any{"name": "World"},
want: "",
wantErr: true,
},
{
name: "missing field",
templateName: "test",
templateString: "{{.missing}}",
data: map[string]any{"name": "World"},
want: "<no value>",
wantErr: false,
},
{
name: "invalid function call",
templateName: "test",
templateString: "{{.name.invalid}}",
data: map[string]any{"name": "World"},
want: "",
wantErr: true,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
got, err := tools.PopulateTemplate(tc.templateName, tc.templateString, tc.data)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatalf("incorrect result (-want +got):\n%s", diff)
}
})
}
}
func TestPopulateTemplateWithFunc(t *testing.T) {
// Custom function for testing
customFuncs := template.FuncMap{
"upper": strings.ToUpper,
"add": func(a, b int) int {
return a + b
},
}
tcs := []struct {
name string
templateName string
templateString string
data map[string]any
funcMap template.FuncMap
want string
wantErr bool
}{
{
name: "with custom upper function",
templateName: "test",
templateString: "{{upper .text}}",
data: map[string]any{"text": "hello"},
funcMap: customFuncs,
want: "HELLO",
wantErr: false,
},
{
name: "with custom add function",
templateName: "test",
templateString: "Result: {{add .x .y}}",
data: map[string]any{"x": 5, "y": 3},
funcMap: customFuncs,
want: "Result: 8",
wantErr: false,
},
{
name: "nil funcMap",
templateName: "test",
templateString: "Hello {{.name}}",
data: map[string]any{"name": "World"},
funcMap: nil,
want: "Hello World",
wantErr: false,
},
{
name: "combine custom function with regular substitution",
templateName: "test",
templateString: "{{upper .greeting}} {{.name}}!",
data: map[string]any{"greeting": "hello", "name": "Alice"},
funcMap: customFuncs,
want: "HELLO Alice!",
wantErr: false,
},
{
name: "undefined function",
templateName: "test",
templateString: "{{undefined .text}}",
data: map[string]any{"text": "hello"},
funcMap: nil,
want: "",
wantErr: true,
},
{
name: "wrong number of arguments",
templateName: "test",
templateString: "{{upper}}",
data: map[string]any{},
funcMap: template.FuncMap{"upper": strings.ToUpper},
want: "",
wantErr: true,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
got, err := tools.PopulateTemplateWithFunc(tc.templateName, tc.templateString, tc.data, tc.funcMap)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatalf("incorrect result (-want +got):\n%s", diff)
}
})
}
}
func TestPopulateTemplateWithJSON(t *testing.T) {
tcs := []struct {
name string
templateName string
templateString string
data map[string]any
want string
wantErr bool
}{
{
name: "json string",
templateName: "test",
templateString: "Data: {{json .value}}",
data: map[string]any{"value": "hello"},
want: `Data: "hello"`,
wantErr: false,
},
{
name: "json number",
templateName: "test",
templateString: "Number: {{json .num}}",
data: map[string]any{"num": 42},
want: "Number: 42",
wantErr: false,
},
{
name: "json boolean",
templateName: "test",
templateString: "Bool: {{json .flag}}",
data: map[string]any{"flag": true},
want: "Bool: true",
wantErr: false,
},
{
name: "json array",
templateName: "test",
templateString: "Array: {{json .items}}",
data: map[string]any{"items": []any{"a", "b", "c"}},
want: `Array: ["a","b","c"]`,
wantErr: false,
},
{
name: "json object",
templateName: "test",
templateString: "Object: {{json .obj}}",
data: map[string]any{"obj": map[string]any{"name": "Alice", "age": 30}},
want: `Object: {"age":30,"name":"Alice"}`,
wantErr: false,
},
{
name: "json null",
templateName: "test",
templateString: "Null: {{json .nullValue}}",
data: map[string]any{"nullValue": nil},
want: "Null: null",
wantErr: false,
},
{
name: "combine json with regular substitution",
templateName: "test",
templateString: "User {{.name}} has data: {{json .data}}",
data: map[string]any{"name": "Bob", "data": map[string]any{"id": 123}},
want: `User Bob has data: {"id":123}`,
wantErr: false,
},
{
name: "missing field for json",
templateName: "test",
templateString: "{{json .missing}}",
data: map[string]any{},
want: "null",
wantErr: false,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
got, err := tools.PopulateTemplateWithJSON(tc.templateName, tc.templateString, tc.data)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatalf("incorrect result (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -131,7 +131,7 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken
if err := util.ValidateDocumentPath(parentPath); err != nil {
return nil, fmt.Errorf("invalid parent document path: %w", err)
}
// List subcollections of the specified document
docRef := t.Client.Doc(parentPath)
collectionRefs, err = docRef.Collections(ctx).GetAll()

View File

@@ -0,0 +1,548 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package firestorequery
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
firestoreapi "cloud.google.com/go/firestore"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
firestoreds "github.com/googleapis/genai-toolbox/internal/sources/firestore"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/tools/firestore/util"
)
// Constants for tool configuration
const (
kind = "firestore-query"
defaultLimit = 100
)
// Firestore operators
var validOperators = map[string]bool{
"<": true,
"<=": true,
">": true,
">=": true,
"==": true,
"!=": true,
"array-contains": true,
"array-contains-any": true,
"in": true,
"not-in": true,
}
// Error messages
const (
errFilterParseFailed = "failed to parse filters: %w"
errQueryExecutionFailed = "failed to execute query: %w"
errTemplateParseFailed = "failed to parse template: %w"
errTemplateExecFailed = "failed to execute template: %w"
errLimitParseFailed = "failed to parse limit value '%s': %w"
errSelectFieldParseFailed = "failed to parse select field: %w"
)
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
// compatibleSource defines the interface for sources that can provide a Firestore client
type compatibleSource interface {
FirestoreClient() *firestoreapi.Client
}
// validate compatible sources are still compatible
var _ compatibleSource = &firestoreds.Source{}
var compatibleSources = [...]string{firestoreds.SourceKind}
// Config represents the configuration for the Firestore query tool
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
// Template fields
CollectionPath string `yaml:"collectionPath" validate:"required"`
Filters string `yaml:"filters"` // JSON string template
Select []string `yaml:"select"` // Fields to select
OrderBy map[string]any `yaml:"orderBy"` // Order by configuration
Limit string `yaml:"limit"` // Limit template (can be a number or template)
AnalyzeQuery bool `yaml:"analyzeQuery"` // Analyze query (boolean, not parameterizable)
// Parameters for template substitution
Parameters tools.Parameters `yaml:"parameters"`
}
// validate interface
var _ tools.ToolConfig = Config{}
// ToolConfigKind returns the kind of tool configuration
func (cfg Config) ToolConfigKind() string {
return kind
}
// Initialize creates a new Tool instance from the configuration
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
// verify source exists
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
// verify the source is compatible
s, ok := rawS.(compatibleSource)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
}
// Set default limit if not specified
if cfg.Limit == "" {
cfg.Limit = fmt.Sprintf("%d", defaultLimit)
}
// Create MCP manifest
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: cfg.Parameters.McpManifest(),
}
// finish tool setup
t := Tool{
Name: cfg.Name,
Kind: kind,
AuthRequired: cfg.AuthRequired,
Client: s.FirestoreClient(),
CollectionPathTemplate: cfg.CollectionPath,
FiltersTemplate: cfg.Filters,
SelectTemplate: cfg.Select,
OrderByTemplate: cfg.OrderBy,
LimitTemplate: cfg.Limit,
AnalyzeQuery: cfg.AnalyzeQuery,
Parameters: cfg.Parameters,
manifest: tools.Manifest{Description: cfg.Description, Parameters: cfg.Parameters.Manifest(), AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}
return t, nil
}
// validate interface
var _ tools.Tool = Tool{}
// Tool represents the Firestore query tool
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
AuthRequired []string `yaml:"authRequired"`
Client *firestoreapi.Client
CollectionPathTemplate string
FiltersTemplate string
SelectTemplate []string
OrderByTemplate map[string]any
LimitTemplate string
AnalyzeQuery bool
Parameters tools.Parameters
manifest tools.Manifest
mcpManifest tools.McpManifest
}
// SimplifiedFilter represents the simplified filter format
type SimplifiedFilter struct {
And []SimplifiedFilter `json:"and,omitempty"`
Or []SimplifiedFilter `json:"or,omitempty"`
Field string `json:"field,omitempty"`
Op string `json:"op,omitempty"`
Value interface{} `json:"value,omitempty"`
}
// OrderByConfig represents ordering configuration
type OrderByConfig struct {
Field string `json:"field"`
Direction string `json:"direction"`
}
// GetDirection returns the Firestore direction constant
func (o *OrderByConfig) GetDirection() firestoreapi.Direction {
if strings.EqualFold(o.Direction, "DESCENDING") || strings.EqualFold(o.Direction, "DESC") {
return firestoreapi.Desc
}
return firestoreapi.Asc
}
// QueryResult represents a document result from the query
type QueryResult struct {
ID string `json:"id"`
Path string `json:"path"`
Data map[string]any `json:"data"`
CreateTime interface{} `json:"createTime,omitempty"`
UpdateTime interface{} `json:"updateTime,omitempty"`
ReadTime interface{} `json:"readTime,omitempty"`
}
// QueryResponse represents the full response including optional metrics
type QueryResponse struct {
Documents []QueryResult `json:"documents"`
ExplainMetrics map[string]any `json:"explainMetrics,omitempty"`
}
// Invoke executes the Firestore query based on the provided parameters
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
paramsMap := params.AsMap()
// Process collection path with template substitution
collectionPath, err := tools.PopulateTemplate("collectionPath", t.CollectionPathTemplate, paramsMap)
if err != nil {
return nil, fmt.Errorf("failed to process collection path: %w", err)
}
// Build the query
query, err := t.buildQuery(collectionPath, paramsMap)
if err != nil {
return nil, err
}
// Execute the query and return results
return t.executeQuery(ctx, query)
}
// buildQuery constructs the Firestore query from parameters
func (t Tool) buildQuery(collectionPath string, params map[string]any) (*firestoreapi.Query, error) {
collection := t.Client.Collection(collectionPath)
query := collection.Query
// Process and apply filters if template is provided
if t.FiltersTemplate != "" {
// Apply template substitution to filters
filtersJSON, err := tools.PopulateTemplateWithJSON("filters", t.FiltersTemplate, params)
if err != nil {
return nil, fmt.Errorf("failed to process filters template: %w", err)
}
// Parse the simplified filter format
var simplifiedFilter SimplifiedFilter
if err := json.Unmarshal([]byte(filtersJSON), &simplifiedFilter); err != nil {
return nil, fmt.Errorf(errFilterParseFailed, err)
}
// Convert simplified filter to Firestore filter
if filter := t.convertToFirestoreFilter(simplifiedFilter); filter != nil {
query = query.WhereEntity(filter)
}
}
// Process select fields
selectFields, err := t.processSelectFields(params)
if err != nil {
return nil, err
}
if len(selectFields) > 0 {
query = query.Select(selectFields...)
}
// Process and apply ordering
orderBy, err := t.getOrderBy(params)
if err != nil {
return nil, err
}
if orderBy != nil {
query = query.OrderBy(orderBy.Field, orderBy.GetDirection())
}
// Process and apply limit
limit, err := t.getLimit(params)
if err != nil {
return nil, err
}
query = query.Limit(limit)
// Apply analyze options if enabled
if t.AnalyzeQuery {
query = query.WithRunOptions(firestoreapi.ExplainOptions{
Analyze: true,
})
}
return &query, nil
}
// convertToFirestoreFilter converts simplified filter format to Firestore EntityFilter
func (t Tool) convertToFirestoreFilter(filter SimplifiedFilter) firestoreapi.EntityFilter {
// Handle AND filters
if len(filter.And) > 0 {
filters := make([]firestoreapi.EntityFilter, 0, len(filter.And))
for _, f := range filter.And {
if converted := t.convertToFirestoreFilter(f); converted != nil {
filters = append(filters, converted)
}
}
if len(filters) > 0 {
return firestoreapi.AndFilter{Filters: filters}
}
return nil
}
// Handle OR filters
if len(filter.Or) > 0 {
filters := make([]firestoreapi.EntityFilter, 0, len(filter.Or))
for _, f := range filter.Or {
if converted := t.convertToFirestoreFilter(f); converted != nil {
filters = append(filters, converted)
}
}
if len(filters) > 0 {
return firestoreapi.OrFilter{Filters: filters}
}
return nil
}
// Handle simple property filter
if filter.Field != "" && filter.Op != "" && filter.Value != nil {
if validOperators[filter.Op] {
// Convert the value using the Firestore native JSON converter
convertedValue, err := util.JSONToFirestoreValue(filter.Value, t.Client)
if err != nil {
// If conversion fails, use the original value
convertedValue = filter.Value
}
return firestoreapi.PropertyFilter{
Path: filter.Field,
Operator: filter.Op,
Value: convertedValue,
}
}
}
return nil
}
// processSelectFields processes the select fields with parameter substitution
func (t Tool) processSelectFields(params map[string]any) ([]string, error) {
var selectFields []string
// Process configured select fields with template substitution
for _, field := range t.SelectTemplate {
// Check if it's a template
if strings.Contains(field, "{{") {
processed, err := tools.PopulateTemplate("selectField", field, params)
if err != nil {
return nil, err
}
if processed != "" {
// The processed field might be an array format [a b c] or a single value
trimmedProcessed := strings.TrimSpace(processed)
// Check if it's in array format [a b c]
if strings.HasPrefix(trimmedProcessed, "[") && strings.HasSuffix(trimmedProcessed, "]") {
// Remove brackets and split by spaces
arrayContent := strings.TrimPrefix(trimmedProcessed, "[")
arrayContent = strings.TrimSuffix(arrayContent, "]")
fields := strings.Fields(arrayContent) // Fields splits by any whitespace
for _, f := range fields {
if f != "" {
selectFields = append(selectFields, f)
}
}
} else {
selectFields = append(selectFields, processed)
}
}
} else {
selectFields = append(selectFields, field)
}
}
return selectFields, nil
}
// getOrderBy processes the orderBy configuration with parameter substitution
func (t Tool) getOrderBy(params map[string]any) (*OrderByConfig, error) {
if t.OrderByTemplate == nil {
return nil, nil
}
orderBy := &OrderByConfig{}
// Process field
field, err := t.getOrderByForKey("field", params)
if err != nil {
return nil, err
}
orderBy.Field = field
// Process direction
direction, err := t.getOrderByForKey("direction", params)
if err != nil {
return nil, err
}
orderBy.Direction = direction
if orderBy.Field == "" {
return nil, nil
}
return orderBy, nil
}
func (t Tool) getOrderByForKey(key string, params map[string]any) (string, error) {
value, ok := t.OrderByTemplate[key].(string)
if !ok {
return "", nil
}
processedValue, err := tools.PopulateTemplate(fmt.Sprintf("orderBy%s", key), value, params)
if err != nil {
return "", err
}
return processedValue, nil
}
// processLimit processes the limit field with parameter substitution
func (t Tool) getLimit(params map[string]any) (int, error) {
limit := defaultLimit
if t.LimitTemplate != "" {
processedValue, err := tools.PopulateTemplate("limit", t.LimitTemplate, params)
if err != nil {
return 0, err
}
// Try to parse as integer
if processedValue != "" {
parsedLimit, err := strconv.Atoi(processedValue)
if err != nil {
return 0, fmt.Errorf(errLimitParseFailed, processedValue, err)
}
limit = parsedLimit
}
}
return limit, nil
}
// executeQuery runs the query and formats the results
func (t Tool) executeQuery(ctx context.Context, query *firestoreapi.Query) (any, error) {
docIterator := query.Documents(ctx)
docs, err := docIterator.GetAll()
if err != nil {
return nil, fmt.Errorf(errQueryExecutionFailed, err)
}
// Convert results to structured format
results := make([]QueryResult, len(docs))
for i, doc := range docs {
results[i] = QueryResult{
ID: doc.Ref.ID,
Path: doc.Ref.Path,
Data: doc.Data(),
CreateTime: doc.CreateTime,
UpdateTime: doc.UpdateTime,
ReadTime: doc.ReadTime,
}
}
// Return with explain metrics if requested
if t.AnalyzeQuery {
explainMetrics, err := t.getExplainMetrics(docIterator)
if err == nil && explainMetrics != nil {
response := QueryResponse{
Documents: results,
ExplainMetrics: explainMetrics,
}
return response, nil
}
}
return results, nil
}
// getExplainMetrics extracts explain metrics from the query iterator
func (t Tool) getExplainMetrics(docIterator *firestoreapi.DocumentIterator) (map[string]any, error) {
explainMetrics, err := docIterator.ExplainMetrics()
if err != nil || explainMetrics == nil {
return nil, err
}
metricsData := make(map[string]any)
// Add plan summary if available
if explainMetrics.PlanSummary != nil {
planSummary := make(map[string]any)
planSummary["indexesUsed"] = explainMetrics.PlanSummary.IndexesUsed
metricsData["planSummary"] = planSummary
}
// Add execution stats if available
if explainMetrics.ExecutionStats != nil {
executionStats := make(map[string]any)
executionStats["resultsReturned"] = explainMetrics.ExecutionStats.ResultsReturned
executionStats["readOperations"] = explainMetrics.ExecutionStats.ReadOperations
if explainMetrics.ExecutionStats.ExecutionDuration != nil {
executionStats["executionDuration"] = explainMetrics.ExecutionStats.ExecutionDuration.String()
}
if explainMetrics.ExecutionStats.DebugStats != nil {
executionStats["debugStats"] = *explainMetrics.ExecutionStats.DebugStats
}
metricsData["executionStats"] = executionStats
}
return metricsData, nil
}
// ParseParams parses and validates input parameters
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.Parameters, data, claims)
}
// Manifest returns the tool manifest
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
// McpManifest returns the MCP manifest
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
// Authorized checks if the tool is authorized based on verified auth services
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func (t Tool) RequiresClientAuthorization() bool {
return false
}

View File

@@ -0,0 +1,492 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package firestorequery_test
import (
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequery"
)
func TestParseFromYamlFirestoreQuery(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example with parameterized collection path",
in: `
tools:
query_users_tool:
kind: firestore-query
source: my-firestore-instance
description: Query users collection with parameterized path
collectionPath: "users/{{.userId}}/documents"
parameters:
- name: userId
type: string
description: The user ID to query documents for
required: true
`,
want: server.ToolConfigs{
"query_users_tool": firestorequery.Config{
Name: "query_users_tool",
Kind: "firestore-query",
Source: "my-firestore-instance",
Description: "Query users collection with parameterized path",
CollectionPath: "users/{{.userId}}/documents",
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("userId", "The user ID to query documents for", true),
},
},
},
},
{
desc: "with parameterized filters",
in: `
tools:
query_products_tool:
kind: firestore-query
source: prod-firestore
description: Query products with dynamic filters
collectionPath: "products"
filters: |
{
"and": [
{"field": "category", "op": "==", "value": {"stringValue": "{{.category}}"}},
{"field": "price", "op": "<=", "value": {"doubleValue": {{.maxPrice}}}}
]
}
parameters:
- name: category
type: string
description: Product category to filter by
required: true
- name: maxPrice
type: float
description: Maximum price for products
required: true
`,
want: server.ToolConfigs{
"query_products_tool": firestorequery.Config{
Name: "query_products_tool",
Kind: "firestore-query",
Source: "prod-firestore",
Description: "Query products with dynamic filters",
CollectionPath: "products",
Filters: `{
"and": [
{"field": "category", "op": "==", "value": {"stringValue": "{{.category}}"}},
{"field": "price", "op": "<=", "value": {"doubleValue": {{.maxPrice}}}}
]
}
`,
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("category", "Product category to filter by", true),
tools.NewFloatParameterWithRequired("maxPrice", "Maximum price for products", true),
},
},
},
},
{
desc: "with select fields and orderBy",
in: `
tools:
query_orders_tool:
kind: firestore-query
source: orders-firestore
description: Query orders with field selection
collectionPath: "orders"
select:
- orderId
- customerName
- totalAmount
orderBy:
field: "{{.sortField}}"
direction: "DESCENDING"
limit: 50
parameters:
- name: sortField
type: string
description: Field to sort by
required: true
`,
want: server.ToolConfigs{
"query_orders_tool": firestorequery.Config{
Name: "query_orders_tool",
Kind: "firestore-query",
Source: "orders-firestore",
Description: "Query orders with field selection",
CollectionPath: "orders",
Select: []string{"orderId", "customerName", "totalAmount"},
OrderBy: map[string]any{
"field": "{{.sortField}}",
"direction": "DESCENDING",
},
Limit: "50",
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("sortField", "Field to sort by", true),
},
},
},
},
{
desc: "with auth requirements and complex filters",
in: `
tools:
secure_query_tool:
kind: firestore-query
source: secure-firestore
description: Query with authentication and complex filters
collectionPath: "{{.collection}}"
filters: |
{
"or": [
{
"and": [
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
{"field": "priority", "op": ">=", "value": {"integerValue": "{{.minPriority}}"}}
]
},
{"field": "urgent", "op": "==", "value": {"booleanValue": true}}
]
}
analyzeQuery: true
authRequired:
- google-auth-service
- api-key-service
parameters:
- name: collection
type: string
description: Collection name to query
required: true
- name: status
type: string
description: Status to filter by
required: true
- name: minPriority
type: integer
description: Minimum priority level
default: 1
`,
want: server.ToolConfigs{
"secure_query_tool": firestorequery.Config{
Name: "secure_query_tool",
Kind: "firestore-query",
Source: "secure-firestore",
Description: "Query with authentication and complex filters",
CollectionPath: "{{.collection}}",
Filters: `{
"or": [
{
"and": [
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
{"field": "priority", "op": ">=", "value": {"integerValue": "{{.minPriority}}"}}
]
},
{"field": "urgent", "op": "==", "value": {"booleanValue": true}}
]
}
`,
AnalyzeQuery: true,
AuthRequired: []string{"google-auth-service", "api-key-service"},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("collection", "Collection name to query", true),
tools.NewStringParameterWithRequired("status", "Status to filter by", true),
tools.NewIntParameterWithDefault("minPriority", 1, "Minimum priority level"),
},
},
},
},
{
desc: "with Firestore native JSON value types and template parameters",
in: `
tools:
query_with_typed_values:
kind: firestore-query
source: typed-firestore
description: Query with Firestore native JSON value types
collectionPath: "countries"
filters: |
{
"or": [
{"field": "continent", "op": "==", "value": {"stringValue": "{{.continent}}"}},
{
"and": [
{"field": "area", "op": ">", "value": {"integerValue": "2000000"}},
{"field": "area", "op": "<", "value": {"integerValue": "3000000"}},
{"field": "population", "op": ">=", "value": {"integerValue": "{{.minPopulation}}"}},
{"field": "gdp", "op": ">", "value": {"doubleValue": {{.minGdp}}}},
{"field": "isActive", "op": "==", "value": {"booleanValue": {{.isActive}}}},
{"field": "lastUpdated", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
]
}
]
}
parameters:
- name: continent
type: string
description: Continent to filter by
required: true
- name: minPopulation
type: string
description: Minimum population as string
required: true
- name: minGdp
type: float
description: Minimum GDP value
required: true
- name: isActive
type: boolean
description: Filter by active status
required: true
- name: startDate
type: string
description: Start date in RFC3339 format
required: true
`,
want: server.ToolConfigs{
"query_with_typed_values": firestorequery.Config{
Name: "query_with_typed_values",
Kind: "firestore-query",
Source: "typed-firestore",
Description: "Query with Firestore native JSON value types",
CollectionPath: "countries",
Filters: `{
"or": [
{"field": "continent", "op": "==", "value": {"stringValue": "{{.continent}}"}},
{
"and": [
{"field": "area", "op": ">", "value": {"integerValue": "2000000"}},
{"field": "area", "op": "<", "value": {"integerValue": "3000000"}},
{"field": "population", "op": ">=", "value": {"integerValue": "{{.minPopulation}}"}},
{"field": "gdp", "op": ">", "value": {"doubleValue": {{.minGdp}}}},
{"field": "isActive", "op": "==", "value": {"booleanValue": {{.isActive}}}},
{"field": "lastUpdated", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
]
}
]
}
`,
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("continent", "Continent to filter by", true),
tools.NewStringParameterWithRequired("minPopulation", "Minimum population as string", true),
tools.NewFloatParameterWithRequired("minGdp", "Minimum GDP value", true),
tools.NewBooleanParameterWithRequired("isActive", "Filter by active status", true),
tools.NewStringParameterWithRequired("startDate", "Start date in RFC3339 format", true),
},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}
func TestParseFromYamlMultipleQueryTools(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
in := `
tools:
query_user_posts:
kind: firestore-query
source: social-firestore
description: Query user posts with filtering
collectionPath: "users/{{.userId}}/posts"
filters: |
{
"and": [
{"field": "visibility", "op": "==", "value": {"stringValue": "{{.visibility}}"}},
{"field": "createdAt", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
]
}
select:
- title
- content
- likes
orderBy:
field: createdAt
direction: "{{.sortOrder}}"
limit: 20
parameters:
- name: userId
type: string
description: User ID whose posts to query
required: true
- name: visibility
type: string
description: Post visibility (public, private, friends)
required: true
- name: startDate
type: string
description: Start date for posts
required: true
- name: sortOrder
type: string
description: Sort order (ASCENDING or DESCENDING)
default: "DESCENDING"
query_inventory:
kind: firestore-query
source: inventory-firestore
description: Query inventory items
collectionPath: "warehouses/{{.warehouseId}}/inventory"
filters: |
{
"field": "quantity", "op": "<", "value": {"integerValue": "{{.threshold}}"}}
parameters:
- name: warehouseId
type: string
description: Warehouse ID to check inventory
required: true
- name: threshold
type: integer
description: Quantity threshold for low stock
required: true
query_transactions:
kind: firestore-query
source: finance-firestore
description: Query financial transactions
collectionPath: "accounts/{{.accountId}}/transactions"
filters: |
{
"or": [
{"field": "type", "op": "==", "value": {"stringValue": "{{.transactionType}}"}},
{"field": "amount", "op": ">", "value": {"doubleValue": {{.minAmount}}}}
]
}
analyzeQuery: true
authRequired:
- finance-auth
parameters:
- name: accountId
type: string
description: Account ID for transactions
required: true
- name: transactionType
type: string
description: Type of transaction
default: "all"
- name: minAmount
type: float
description: Minimum transaction amount
default: 0
`
want := server.ToolConfigs{
"query_user_posts": firestorequery.Config{
Name: "query_user_posts",
Kind: "firestore-query",
Source: "social-firestore",
Description: "Query user posts with filtering",
CollectionPath: "users/{{.userId}}/posts",
Filters: `{
"and": [
{"field": "visibility", "op": "==", "value": {"stringValue": "{{.visibility}}"}},
{"field": "createdAt", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
]
}
`,
Select: []string{"title", "content", "likes"},
OrderBy: map[string]any{
"field": "createdAt",
"direction": "{{.sortOrder}}",
},
Limit: "20",
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("userId", "User ID whose posts to query", true),
tools.NewStringParameterWithRequired("visibility", "Post visibility (public, private, friends)", true),
tools.NewStringParameterWithRequired("startDate", "Start date for posts", true),
tools.NewStringParameterWithDefault("sortOrder", "DESCENDING", "Sort order (ASCENDING or DESCENDING)"),
},
},
"query_inventory": firestorequery.Config{
Name: "query_inventory",
Kind: "firestore-query",
Source: "inventory-firestore",
Description: "Query inventory items",
CollectionPath: "warehouses/{{.warehouseId}}/inventory",
Filters: `{
"field": "quantity", "op": "<", "value": {"integerValue": "{{.threshold}}"}}
`,
AuthRequired: []string{},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("warehouseId", "Warehouse ID to check inventory", true),
tools.NewIntParameterWithRequired("threshold", "Quantity threshold for low stock", true),
},
},
"query_transactions": firestorequery.Config{
Name: "query_transactions",
Kind: "firestore-query",
Source: "finance-firestore",
Description: "Query financial transactions",
CollectionPath: "accounts/{{.accountId}}/transactions",
Filters: `{
"or": [
{"field": "type", "op": "==", "value": {"stringValue": "{{.transactionType}}"}},
{"field": "amount", "op": ">", "value": {"doubleValue": {{.minAmount}}}}
]
}
`,
AnalyzeQuery: true,
AuthRequired: []string{"finance-auth"},
Parameters: tools.Parameters{
tools.NewStringParameterWithRequired("accountId", "Account ID for transactions", true),
tools.NewStringParameterWithDefault("transactionType", "all", "Type of transaction"),
tools.NewFloatParameterWithDefault("minAmount", 0, "Minimum transaction amount"),
},
},
}
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err = yaml.UnmarshalContext(ctx, testutils.FormatYaml(in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
}

View File

@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"slices"
"strings"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
@@ -66,6 +67,14 @@ type ToolConfig interface {
type AccessToken string
func (token AccessToken) ParseBearerToken() (string, error) {
headerParts := strings.Split(string(token), " ")
if len(headerParts) != 2 || strings.ToLower(headerParts[0]) != "bearer" {
return "", fmt.Errorf("authorization header must be in the format 'Bearer <token>': %w", ErrUnauthorized)
}
return headerParts[1], nil
}
type Tool interface {
Invoke(context.Context, ParamValues, AccessToken) (any, error)
ParseParams(map[string]any, map[string]map[string]any) (ParamValues, error)

View File

@@ -0,0 +1,178 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yugabytedbsql
import (
"context"
"fmt"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/sources/yugabytedb"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/yugabyte/pgx/v5/pgxpool"
)
const kind string = "yugabytedb-sql"
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type compatibleSource interface {
YugabyteDBPool() *pgxpool.Pool
}
var compatibleSources = [...]string{yugabytedb.SourceKind}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
Statement string `yaml:"statement" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters `yaml:"parameters"`
TemplateParameters tools.Parameters `yaml:"templateParameters"`
}
// validate interface
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
// verify source exists
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
// verify the source is compatible
s, ok := rawS.(compatibleSource)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
}
allParameters, paramManifest, paramMcpManifest, err := tools.ProcessParameters(cfg.TemplateParameters, cfg.Parameters)
if err != nil {
return nil, err
}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: paramMcpManifest,
}
// finish tool setup
t := Tool{
Name: cfg.Name,
Kind: kind,
Parameters: cfg.Parameters,
TemplateParameters: cfg.TemplateParameters,
AllParams: allParameters,
Statement: cfg.Statement,
AuthRequired: cfg.AuthRequired,
Pool: s.YugabyteDBPool(),
manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}
return t, nil
}
// validate interface
var _ tools.Tool = Tool{}
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters `yaml:"parameters"`
TemplateParameters tools.Parameters `yaml:"templateParameters"`
AllParams tools.Parameters `yaml:"allParams"`
Pool *pgxpool.Pool
Statement string
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
paramsMap := params.AsMap()
newStatement, err := tools.ResolveTemplateParams(t.TemplateParameters, t.Statement, paramsMap)
if err != nil {
return nil, fmt.Errorf("unable to extract template params %w", err)
}
newParams, err := tools.GetParams(t.Parameters, paramsMap)
if err != nil {
return nil, fmt.Errorf("unable to extract standard params %w", err)
}
sliceParams := newParams.AsSlice()
results, err := t.Pool.Query(ctx, newStatement, sliceParams...)
if err != nil {
return nil, fmt.Errorf("unable to execute query: %w", err)
}
fields := results.FieldDescriptions()
var out []any
for results.Next() {
v, err := results.Values()
if err != nil {
return nil, fmt.Errorf("unable to parse row: %w", err)
}
vMap := make(map[string]any)
for i, f := range fields {
vMap[f.Name] = v[i]
}
out = append(out, vMap)
}
return out, nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.AllParams, data, claims)
}
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func (t Tool) RequiresClientAuthorization() bool {
return false
}

View File

@@ -0,0 +1,214 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yugabytedbsql_test
import (
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/tools/yugabytedbsql"
)
func TestParseFromYamlYugabyteDBSQL(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic valid config",
in: `
tools:
hotel_search:
kind: yugabytedb-sql
source: yb-source
description: search hotels by city
statement: |
SELECT * FROM hotels WHERE city = $1;
authRequired:
- auth-service-a
- auth-service-b
parameters:
- name: city
type: string
description: city name
authServices:
- name: auth-service-a
field: user_id
- name: auth-service-b
field: user_id
`,
want: server.ToolConfigs{
"hotel_search": yugabytedbsql.Config{
Name: "hotel_search",
Kind: "yugabytedb-sql",
Source: "yb-source",
Description: "search hotels by city",
Statement: "SELECT * FROM hotels WHERE city = $1;\n",
AuthRequired: []string{"auth-service-a", "auth-service-b"},
Parameters: []tools.Parameter{
tools.NewStringParameterWithAuth("city", "city name",
[]tools.ParamAuthService{
{Name: "auth-service-a", Field: "user_id"},
{Name: "auth-service-b", Field: "user_id"},
},
),
},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}
func TestFailParseFromYamlYugabyteDBSQL(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
cases := []struct {
desc string
in string
}{
{
desc: "missing required field (statement)",
in: `
tools:
tool1:
kind: yugabytedb-sql
source: yb-source
description: incomplete config
`,
},
{
desc: "unknown field (foo)",
in: `
tools:
tool2:
kind: yugabytedb-sql
source: yb-source
description: test
statement: SELECT 1;
foo: bar
`,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
cfg := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &cfg)
if err == nil {
t.Fatalf("expected error but got none")
}
})
}
}
func TestParseFromYamlWithTemplateParamsYugabyteDB(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
tools:
example_tool:
kind: yugabytedb-sql
source: my-yb-instance
description: some description
statement: |
SELECT * FROM SQL_STATEMENT;
parameters:
- name: name
type: string
description: some description
templateParameters:
- name: tableName
type: string
description: The table to select hotels from.
- name: fieldArray
type: array
description: The columns to return for the query.
items:
name: column
type: string
description: A column name that will be returned from the query.
`,
want: server.ToolConfigs{
"example_tool": yugabytedbsql.Config{
Name: "example_tool",
Kind: "yugabytedb-sql",
Source: "my-yb-instance",
Description: "some description",
Statement: "SELECT * FROM SQL_STATEMENT;\n",
AuthRequired: []string{},
Parameters: []tools.Parameter{
tools.NewStringParameter("name", "some description"),
},
TemplateParameters: []tools.Parameter{
tools.NewStringParameter("tableName", "The table to select hotels from."),
tools.NewArrayParameter("fieldArray", "The columns to return for the query.", tools.NewStringParameter("column", "A column name that will be returned from the query.")),
},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}

View File

@@ -542,6 +542,7 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -824,6 +825,7 @@ func runBigQueryForecastToolInvokeTest(t *testing.T, tableName string) {
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
historyDataTable := strings.ReplaceAll(tableName, "`", "")
historyDataQuery := fmt.Sprintf("SELECT ts, data, id FROM %s", tableName)
@@ -1040,6 +1042,7 @@ func runBigQueryListDatasetToolInvokeTest(t *testing.T, datasetWant string) {
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -1161,6 +1164,7 @@ func runBigQueryGetDatasetInfoToolInvokeTest(t *testing.T, datasetName, datasetI
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -1310,6 +1314,7 @@ func runBigQueryListTableIdsToolInvokeTest(t *testing.T, datasetName, tablename_
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -1459,6 +1464,7 @@ func runBigQueryGetTableInfoToolInvokeTest(t *testing.T, datasetName, tableName,
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -1608,6 +1614,7 @@ func runBigQueryConversationalAnalyticsInvokeTest(t *testing.T, datasetName, tab
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
tableRefsJSON := fmt.Sprintf(`[{"projectId":"%s","datasetId":"%s","tableId":"%s"}]`, BigqueryProject, datasetName, tableName)

View File

@@ -0,0 +1,284 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloudsql
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"sync"
"testing"
"time"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
_ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlwaitforoperation"
)
var (
cloudsqlWaitToolKind = "cloud-sql-wait-for-operation"
)
type cloudsqlOperation struct {
Name string `json:"name"`
Status string `json:"status"`
TargetLink string `json:"targetLink"`
OperationType string `json:"operationType"`
Error *struct {
Errors []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"errors"`
} `json:"error,omitempty"`
}
type cloudsqlInstance struct {
Region string `json:"region"`
DatabaseVersion string `json:"databaseVersion"`
}
type cloudsqlHandler struct {
mu sync.Mutex
operations map[string]*cloudsqlOperation
instances map[string]*cloudsqlInstance
}
func (h *cloudsqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
defer h.mu.Unlock()
if match, _ := regexp.MatchString("/v1/projects/p1/operations/.*", r.URL.Path); match {
parts := regexp.MustCompile("/").Split(r.URL.Path, -1)
opName := parts[len(parts)-1]
op, ok := h.operations[opName]
if !ok {
http.NotFound(w, r)
return
}
if op.Status != "DONE" {
op.Status = "DONE"
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(op); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else if match, _ := regexp.MatchString("/v1/projects/p1/instances/.*", r.URL.Path); match {
parts := regexp.MustCompile("/").Split(r.URL.Path, -1)
instanceName := parts[len(parts)-1]
instance, ok := h.instances[instanceName]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(instance); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.NotFound(w, r)
}
}
func TestCloudSQLWaitToolEndpoints(t *testing.T) {
h := &cloudsqlHandler{
operations: map[string]*cloudsqlOperation{
"op1": {Name: "op1", Status: "PENDING", OperationType: "CREATE_DATABASE"},
"op2": {Name: "op2", Status: "PENDING", OperationType: "CREATE_DATABASE", Error: &struct {
Errors []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}{
Errors: []struct {
Code string `json:"code"`
Message string `json:"message"`
}{
{Code: "ERROR_CODE", Message: "failed"},
},
}},
"op3": {Name: "op3", Status: "PENDING", OperationType: "CREATE"},
},
instances: map[string]*cloudsqlInstance{
"i1": {Region: "r1", DatabaseVersion: "POSTGRES_13"},
},
}
server := httptest.NewServer(h)
defer server.Close()
h.operations["op1"].TargetLink = fmt.Sprintf("%s/v1/projects/p1/instances/i1/databases/d1", server.URL)
h.operations["op2"].TargetLink = fmt.Sprintf("%s/v1/projects/p1/instances/i2/databases/d2", server.URL)
h.operations["op3"].TargetLink = fmt.Sprintf("%s/v1/projects/p1/instances/i1", server.URL)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
var args []string
toolsFile := getCloudSQLWaitToolsConfig(server.URL)
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
if err != nil {
t.Fatalf("command initialization returned an error: %s", err)
}
defer cleanup()
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
if err != nil {
t.Logf("toolbox command logs: \n%s", out)
t.Fatalf("toolbox didn't start successfully: %s", err)
}
tcs := []struct {
name string
toolName string
body string
want string
expectError bool
wantSubstring bool
}{
{
name: "successful operation",
toolName: "wait-for-op1",
body: `{"project": "p1", "operation": "op1"}`,
want: "Your Cloud SQL resource is ready",
wantSubstring: true,
},
{
name: "failed operation",
toolName: "wait-for-op2",
body: `{"project": "p1", "operation": "op2"}`,
expectError: true,
},
{
name: "non-database create operation",
toolName: "wait-for-op3",
body: `{"project": "p1", "operation": "op3"}`,
want: `{"name":"op3","status":"DONE","targetLink":"` + h.operations["op3"].TargetLink + `","operationType":"CREATE"}`,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
api := fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", tc.toolName)
req, err := http.NewRequest(http.MethodPost, api, bytes.NewBufferString(tc.body))
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if tc.expectError {
if resp.StatusCode == http.StatusOK {
t.Fatal("expected error but got status 200")
}
return
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
if tc.wantSubstring {
var result struct {
Result string `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !bytes.Contains([]byte(result.Result), []byte(tc.want)) {
t.Fatalf("unexpected result: got %q, want substring %q", result.Result, tc.want)
}
return
}
var result struct {
Result string `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var tempString string
if err := json.Unmarshal([]byte(result.Result), &tempString); err != nil {
t.Fatalf("failed to unmarshal outer JSON string: %v", err)
}
var got, want map[string]any
if err := json.Unmarshal([]byte(tempString), &got); err != nil {
t.Fatalf("failed to unmarshal inner JSON object: %v", err)
}
if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
t.Fatalf("failed to unmarshal want: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected result: got %+v, want %+v", got, want)
}
})
}
}
func getCloudSQLWaitToolsConfig(baseURL string) map[string]any {
return map[string]any{
"sources": map[string]any{
"test-source": map[string]any{
"kind": "http",
"baseUrl": baseURL,
},
},
"tools": map[string]any{
"wait-for-op1": map[string]any{
"kind": cloudsqlWaitToolKind,
"source": "test-source",
"description": "wait for op1",
"baseURL": baseURL,
"authRequired": []string{},
},
"wait-for-op2": map[string]any{
"kind": cloudsqlWaitToolKind,
"source": "test-source",
"description": "wait for op2",
"baseURL": baseURL,
"authRequired": []string{},
},
"wait-for-op3": map[string]any{
"kind": cloudsqlWaitToolKind,
"source": "test-source",
"description": "wait for op3",
"baseURL": baseURL,
"authRequired": []string{},
},
},
}
}

View File

@@ -131,6 +131,8 @@ func TestFirestoreToolEndpoints(t *testing.T) {
// Run specific Firestore tool tests
runFirestoreGetDocumentsTest(t, docPath1, docPath2)
runFirestoreQueryCollectionTest(t, testCollectionName)
runFirestoreQueryTest(t, testCollectionName)
runFirestoreQuerySelectArrayTest(t, testCollectionName)
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
runFirestoreAddDocumentsTest(t, testCollectionName)
runFirestoreUpdateDocumentTest(t, testCollectionName, testDocID1)
@@ -562,6 +564,63 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
"source": "my-instance",
"description": "Query a Firestore collection",
},
"firestore-query-param": map[string]any{
"kind": "firestore-query",
"source": "my-instance",
"description": "Query a Firestore collection with parameterizable filters",
"collectionPath": "{{.collection}}",
"filters": `{
"field": "age", "op": "{{.operator}}", "value": {"integerValue": "{{.ageValue}}"}
}`,
"limit": 10,
"parameters": []map[string]any{
{
"name": "collection",
"type": "string",
"description": "Collection to query",
"required": true,
},
{
"name": "operator",
"type": "string",
"description": "Comparison operator",
"required": true,
},
{
"name": "ageValue",
"type": "string",
"description": "Age value to compare",
"required": true,
},
},
},
"firestore-query-select-array": map[string]any{
"kind": "firestore-query",
"source": "my-instance",
"description": "Query with array-based select fields",
"collectionPath": "{{.collection}}",
"select": []string{"{{.fields}}"},
"limit": 10,
"parameters": []map[string]any{
{
"name": "collection",
"type": "string",
"description": "Collection to query",
"required": true,
},
{
"name": "fields",
"type": "array",
"description": "Fields to select",
"required": true,
"items": map[string]any{
"name": "field",
"type": "string",
"description": "field",
},
},
},
},
"firestore-get-rules": map[string]any{
"kind": "firestore-get-rules",
"source": "my-instance",
@@ -1356,6 +1415,246 @@ func runFirestoreDeleteDocumentsTest(t *testing.T, docPath string) {
}
}
func runFirestoreQueryTest(t *testing.T, collectionName string) {
invokeTcs := []struct {
name string
api string
requestBody io.Reader
wantRegex string
isErr bool
}{
{
name: "query with parameterized filters - age greater than",
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"operator": ">",
"ageValue": "25"
}`, collectionName))),
wantRegex: `"name":"Alice"`,
isErr: false,
},
{
name: "query with parameterized filters - exact name match",
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"operator": "==",
"ageValue": "25"
}`, collectionName))),
wantRegex: `"name":"Bob"`,
isErr: false,
},
{
name: "query with parameterized filters - age less than or equal",
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"operator": "<=",
"ageValue": "29"
}`, collectionName))),
wantRegex: `"name":"Bob"`,
isErr: false,
},
{
name: "missing required parameter",
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
requestBody: bytes.NewBuffer([]byte(`{"collection": "test", "operator": ">"}`)),
isErr: true,
},
{
name: "query non-existent collection with parameters",
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
requestBody: bytes.NewBuffer([]byte(`{
"collection": "non-existent-collection",
"operator": "==",
"ageValue": "30"
}`)),
wantRegex: `^\[\]$`, // Empty array
isErr: false,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if tc.isErr {
return
}
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body: %v", err)
}
got, ok := body["result"].(string)
if !ok {
t.Fatalf("unable to find result in response body")
}
if tc.wantRegex != "" {
matched, err := regexp.MatchString(tc.wantRegex, got)
if err != nil {
t.Fatalf("invalid regex pattern: %v", err)
}
if !matched {
t.Fatalf("result does not match expected pattern.\nGot: %s\nWant pattern: %s", got, tc.wantRegex)
}
}
})
}
}
func runFirestoreQuerySelectArrayTest(t *testing.T, collectionName string) {
invokeTcs := []struct {
name string
api string
requestBody io.Reader
wantRegex string
validateFields bool
isErr bool
}{
{
name: "query with array select fields - single field",
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"fields": ["name"]
}`, collectionName))),
wantRegex: `"name":"`,
validateFields: true,
isErr: false,
},
{
name: "query with array select fields - multiple fields",
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"fields": ["name", "age"]
}`, collectionName))),
wantRegex: `"name":".*"age":`,
validateFields: true,
isErr: false,
},
{
name: "query with empty array select fields",
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collection": "%s",
"fields": []
}`, collectionName))),
wantRegex: `\[.*\]`, // Should return documents with all fields
isErr: false,
},
{
name: "missing fields parameter",
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collection": "%s"}`, collectionName))),
isErr: true,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if tc.isErr {
return
}
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body: %v", err)
}
got, ok := body["result"].(string)
if !ok {
t.Fatalf("unable to find result in response body")
}
if tc.wantRegex != "" {
matched, err := regexp.MatchString(tc.wantRegex, got)
if err != nil {
t.Fatalf("invalid regex pattern: %v", err)
}
if !matched {
t.Fatalf("result does not match expected pattern.\nGot: %s\nWant pattern: %s", got, tc.wantRegex)
}
}
// Additional validation for field selection
if tc.validateFields {
// Parse the result to check if only selected fields are present
var results []map[string]interface{}
err = json.Unmarshal([]byte(got), &results)
if err != nil {
t.Fatalf("error parsing result as JSON array: %v", err)
}
// For single field test, ensure only 'name' field is present in data
if tc.name == "query with array select fields - single field" && len(results) > 0 {
for _, result := range results {
if data, ok := result["data"].(map[string]interface{}); ok {
if _, hasName := data["name"]; !hasName {
t.Fatalf("expected 'name' field in data, but not found")
}
// The 'age' field should not be present when only 'name' is selected
if _, hasAge := data["age"]; hasAge {
t.Fatalf("unexpected 'age' field in data when only 'name' was selected")
}
}
}
}
// For multiple fields test, ensure both fields are present
if tc.name == "query with array select fields - multiple fields" && len(results) > 0 {
for _, result := range results {
if data, ok := result["data"].(map[string]interface{}); ok {
if _, hasName := data["name"]; !hasName {
t.Fatalf("expected 'name' field in data, but not found")
}
if _, hasAge := data["age"]; !hasAge {
t.Fatalf("expected 'age' field in data, but not found")
}
}
}
}
}
})
}
}
func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
invokeTcs := []struct {
name string
@@ -1385,7 +1684,7 @@ func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
"orderBy": "{\"field\": \"age\", \"direction\": \"DESCENDING\"}",
"limit": 2
}`, collectionName))),
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30)
isErr: false,
},
{

View File

@@ -273,6 +273,7 @@ func RunToolInvokeTest(t *testing.T, select1Want string, options ...InvokeTestOp
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
// Test tool invoke endpoint
invokeTcs := []struct {
@@ -841,6 +842,7 @@ func RunMCPToolCallMethod(t *testing.T, myFailToolWant, select1Want string, opti
if err != nil {
t.Fatalf("error getting access token from ADC: %s", err)
}
accessToken = "Bearer " + accessToken
idToken, err := GetGoogleIdToken(ClientId)
if err != nil {

View File

@@ -0,0 +1,157 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yugabytedb
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
"github.com/yugabyte/pgx/v5/pgxpool"
)
var (
YBDB_SOURCE_KIND = "yugabytedb"
YBDB_TOOL_KIND = "yugabytedb-sql"
YBDB_DATABASE = os.Getenv("YUGABYTEDB_DATABASE")
YBDB_HOST = os.Getenv("YUGABYTEDB_HOST")
YBDB_PORT = os.Getenv("YUGABYTEDB_PORT")
YBDB_USER = os.Getenv("YUGABYTEDB_USER")
YBDB_PASS = os.Getenv("YUGABYTEDB_PASS")
YBDB_LB = os.Getenv("YUGABYTEDB_LOADBALANCE")
)
func getYBVars(t *testing.T) map[string]any {
switch "" {
case YBDB_DATABASE:
t.Fatal("'YUGABYTEDB_DATABASE' not set")
case YBDB_HOST:
t.Fatal("'YUGABYTEDB_HOST' not set")
case YBDB_PORT:
t.Fatal("'YUGABYTEDB_PORT' not set")
case YBDB_USER:
t.Fatal("'YUGABYTEDB_USER' not set")
case YBDB_PASS:
t.Fatal("'YUGABYTEDB_PASS' not set")
case YBDB_LB:
fmt.Printf("YUGABYTEDB_LOADBALANCE value not set. Setting default value: false")
YBDB_LB = "false"
}
return map[string]any{
"kind": YBDB_SOURCE_KIND,
"host": YBDB_HOST,
"port": YBDB_PORT,
"database": YBDB_DATABASE,
"user": YBDB_USER,
"password": YBDB_PASS,
"loadBalance": YBDB_LB,
}
}
func initYBConnectionPool(host, port, user, pass, dbname, loadBalance string) (*pgxpool.Pool, error) {
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?load_balance=%s", user, pass, host, port, dbname, loadBalance)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return nil, fmt.Errorf("unable to create YugabyteDB connection pool: %w", err)
}
return pool, nil
}
// SetupYugabyteDBSQLTable creates and inserts data into a table of tool
// compatible with yugabytedb-sql tool
func SetupYugabyteDBSQLTable(t *testing.T, ctx context.Context, pool *pgxpool.Pool, create_statement, insert_statement, tableName string, params []any) func(*testing.T) {
err := pool.Ping(ctx)
if err != nil {
t.Fatalf("unable to connect to test database: %s", err)
}
// Create table
_, err = pool.Query(ctx, create_statement)
if err != nil {
t.Fatalf("unable to create test table %s: %s", tableName, err)
}
// Insert test data
_, err = pool.Query(ctx, insert_statement, params...)
if err != nil {
t.Fatalf("unable to insert test data: %s", err)
}
return func(t *testing.T) {
// tear down test
_, err = pool.Exec(ctx, fmt.Sprintf("DROP TABLE %s;", tableName))
if err != nil {
t.Errorf("Teardown failed: %s", err)
}
}
}
func TestYugabyteDB(t *testing.T) {
sourceConfig := getYBVars(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
var args []string
pool, err := initYBConnectionPool(YBDB_HOST, YBDB_PORT, YBDB_USER, YBDB_PASS, YBDB_DATABASE, YBDB_LB)
if err != nil {
t.Fatalf("unable to create YugabyteDB connection pool: %s", err)
}
tableNameParam := "param_table_" + strings.ReplaceAll(uuid.New().String(), "-", "")
tableNameAuth := "auth_table_" + strings.ReplaceAll(uuid.New().String(), "-", "")
tableNameTemplateParam := "template_param_table_" + strings.ReplaceAll(uuid.New().String(), "-", "")
createParamTableStmt, insertParamTableStmt, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, paramTestParams := tests.GetPostgresSQLParamToolInfo(tableNameParam)
teardownTable1 := SetupYugabyteDBSQLTable(t, ctx, pool, createParamTableStmt, insertParamTableStmt, tableNameParam, paramTestParams)
defer teardownTable1(t)
createAuthTableStmt, insertAuthTableStmt, authToolStmt, authTestParams := tests.GetPostgresSQLAuthToolInfo(tableNameAuth)
teardownTable2 := SetupYugabyteDBSQLTable(t, ctx, pool, createAuthTableStmt, insertAuthTableStmt, tableNameAuth, authTestParams)
defer teardownTable2(t)
toolsFile := tests.GetToolsConfig(sourceConfig, YBDB_TOOL_KIND, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, authToolStmt)
tmplSelectCombined, tmplSelectFilterCombined := tests.GetPostgresSQLTmplToolStatement()
toolsFile = tests.AddTemplateParamConfig(t, toolsFile, YBDB_TOOL_KIND, tmplSelectCombined, tmplSelectFilterCombined, "")
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
if err != nil {
t.Fatalf("command initialization returned an error: %s", err)
}
defer cleanup()
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
if err != nil {
t.Logf("toolbox command logs: \n%s", out)
t.Fatalf("toolbox didn't start successfully: %s", err)
}
select1Want, mcpMyFailToolWant, _, mcpSelect1Want := tests.GetPostgresWants()
tests.RunToolGetTest(t)
tests.RunToolInvokeTest(t, select1Want)
tests.RunMCPToolCallMethod(t, mcpMyFailToolWant, mcpSelect1Want)
tests.RunToolInvokeWithTemplateParameters(t, tableNameTemplateParam)
}