Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Talreja
d4e2c24633 feat: inspect sources from Toolbox UI 2025-08-04 22:16:45 +00:00
AlexTalreja
8749b03003 feat: interactive web UI for Toolbox (#1065)
Introduce Toolbox UI, which can be launched with the `--ui` flag. 

This initial version of Toolbox UI allows users to test Toolbox by
inspecting tools/toolsets, modifying parameters, managing headers, and
executing API calls.
2025-08-04 11:47:38 -07:00
prernakakkar-google
bfabcf826e docs: Redirect alloydb pages to cgc (#1064) 2025-08-04 10:53:45 -07:00
Yuan Teoh
e843f73079 chore(server/mcp): update to accept other json content type request (#1049)
Toolbox MCP endpoint to accept request of multiple content type
according to the json schema
(https://www.jsonrpc.org/historical/json-rpc-over-http.html#http-header)

Toolbox endpoints only accept `Content-Type: application/json`. Update
to accept `Content-Type: application/json-rpc` and
`Content-Type:application/jsonrequest` as well.

Fixes #1004
2025-08-02 08:04:46 +00:00
Cheese
6eaf36ac85 feat: support tidb in data source, sql tool, and execute sql tool (#829)
This PR supports TiDB in:

1. sources - tidb: As a data source;
2. tools - tidb - tidbsql: As a prepared SQL running tool;
3. tools - tidb - tidbexecutesql: As an arbitrary SQL running tool (for
development purposes).

And its corresponding docs.

---------

Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
Co-authored-by: Yuan Teoh <yuanteoh@google.com>
2025-08-02 00:54:22 -07:00
Huan Chen
051e686476 fix(tools/bigquery-execute-sql): update the return messages (#1034)
Updated return message to make sure all cases are covered.
2025-08-01 14:39:16 -07:00
Huan Chen
9af55b651d fix(tools/bigquery-sql): ensure invoke always returns a non-null value (#1020)
This is to make bigquery-sql consistent with bigquery-execute-sql. May
not be necessary to have.

- Added a dry run step to identify the query type (e.g., SELECT, DML),
which allows the tool to correctly handle the query's output.
- The recommended high-level client, cloud.google.com/go/bigquery, does
not expose the statement type from a dry run. To circumvent this
limitation, the low-level BigQuery REST API client
(google.golang.org/api/bigquery/v2) was added to gain access to these
necessary details.

---------

Co-authored-by: Averi Kitsch <akitsch@google.com>
2025-08-01 14:16:57 -07:00
Anuj Jhunjhunwala
5fa1660fc8 feat(tools/dataplex-lookup-entry): Add support for dataplex-lookup-entry tool (#1009)
Added support for lookup entry tool in Dataplex.
Fixes #997

---------

Co-authored-by: Wenxin Du <117315983+duwenxin99@users.noreply.github.com>
2025-08-01 15:18:56 -04:00
Yuan Teoh
d9ee17d2c7 chore(sources/duckdb): run lint (#1048) 2025-08-01 09:15:57 -07:00
59 changed files with 5190 additions and 521 deletions

View File

@@ -486,7 +486,7 @@ steps:
"Looker" \
looker \
looker
- id: "duckdb"
name: golang:1
waitFor: ["compile-test-binary"]
@@ -506,7 +506,6 @@ steps:
duckdb \
duckdb
- id: "alloydbwaitforoperation"
name: golang:1
waitFor: ["compile-test-binary"]
@@ -525,6 +524,28 @@ steps:
"Alloydb Wait for Operation" \
utility \
utility/alloydbwaitforoperation
- id: "tidb"
name: golang:1
waitFor: ["compile-test-binary"]
entrypoint: /bin/bash
env:
- "GOPATH=/gopath"
- "TIDB_DATABASE=$_DATABASE_NAME"
- "TIDB_HOST=$_TIDB_HOST"
- "TIDB_PORT=$_TIDB_PORT"
- "SERVICE_ACCOUNT_EMAIL=$SERVICE_ACCOUNT_EMAIL"
secretEnv: ["CLIENT_ID", "TIDB_USER", "TIDB_PASS"]
volumes:
- name: "go"
path: "/gopath"
args:
- -c
- |
.ci/test_with_coverage.sh \
"TiDB" \
tidb \
tidbsql tidbexecutesql
availableSecrets:
secretManager:
@@ -584,7 +605,10 @@ availableSecrets:
env: LOOKER_CLIENT_ID
- versionName: projects/107716898620/secrets/looker_client_secret/versions/latest
env: LOOKER_CLIENT_SECRET
- versionName: projects/107716898620/secrets/tidb_user/versions/latest
env: TIDB_USER
- versionName: projects/107716898620/secrets/tidb_pass/versions/latest
env: TIDB_PASS
options:
logging: CLOUD_LOGGING_ONLY
@@ -616,4 +640,6 @@ substitutions:
_DGRAPHURL: "https://play.dgraph.io"
_COUCHBASE_BUCKET: "couchbase-bucket"
_COUCHBASE_SCOPE: "couchbase-scope"
_LOOKER_VERIFY_SSL: "true"
_LOOKER_VERIFY_SSL: "true"
_TIDB_HOST: 127.0.0.1
_TIDB_PORT: "4000"

View File

@@ -1,20 +0,0 @@
load("//tools/build_defs/go:go_library.bzl", "go_library")
load("//tools/build_defs/go:go_test.bzl", "go_test")
go_library(
name = "cmd",
srcs = [
"options.go",
"root.go",
],
embedsrcs = ["version.txt"],
)
go_test(
name = "cmd_test",
srcs = [
"options_test.go",
"root_test.go",
],
library = ":cmd",
)

View File

@@ -51,6 +51,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/bigquery/bigquerysql"
_ "github.com/googleapis/genai-toolbox/internal/tools/bigtable"
_ "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/dataplexsearchentries"
_ "github.com/googleapis/genai-toolbox/internal/tools/dgraph"
_ "github.com/googleapis/genai-toolbox/internal/tools/duckdbsql"
@@ -94,6 +95,8 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerexecutesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannersql"
_ "github.com/googleapis/genai-toolbox/internal/tools/sqlitesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/tidb/tidbexecutesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/tidb/tidbsql"
_ "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"
@@ -121,6 +124,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/sources/redis"
_ "github.com/googleapis/genai-toolbox/internal/sources/spanner"
_ "github.com/googleapis/genai-toolbox/internal/sources/sqlite"
_ "github.com/googleapis/genai-toolbox/internal/sources/tidb"
_ "github.com/googleapis/genai-toolbox/internal/sources/valkey"
)
@@ -222,6 +226,7 @@ func NewCommand(opts ...Option) *Command {
flags.StringVar(&cmd.prebuiltConfig, "prebuilt", "", "Use a prebuilt tool configuration by source type. Cannot be used with --tools-file. Allowed: 'alloydb-postgres-admin', alloydb-postgres', 'bigquery', 'cloud-sql-mysql', 'cloud-sql-postgres', 'cloud-sql-mssql', 'dataplex', 'firestore', 'looker', 'mssql', 'mysql', 'postgres', 'spanner', 'spanner-postgres'.")
flags.BoolVar(&cmd.cfg.Stdio, "stdio", false, "Listens via MCP STDIO instead of acting as a remote HTTP server.")
flags.BoolVar(&cmd.cfg.DisableReload, "disable-reload", false, "Disables dynamic reloading of tools file.")
flags.BoolVar(&cmd.cfg.UI, "ui", false, "Launches the Toolbox UI web server.")
// wrap RunE command so that we have access to original Command object
cmd.RunE = func(*cobra.Command, []string) error { return run(cmd) }
@@ -798,6 +803,9 @@ func run(cmd *Command) error {
return errMsg
}
cmd.logger.InfoContext(ctx, "Server ready to serve!")
if cmd.cfg.UI {
cmd.logger.InfoContext(ctx, "Toolbox UI is up and running at: http://localhost:5000/ui")
}
go func() {
defer close(srvErr)

View File

@@ -1250,7 +1250,7 @@ func TestPrebuiltTools(t *testing.T) {
wantToolset: server.ToolsetConfigs{
"dataplex-tools": tools.ToolsetConfig{
Name: "dataplex-tools",
ToolNames: []string{"dataplex_search_entries"},
ToolNames: []string{"dataplex_search_entries", "dataplex_lookup_entry"},
},
},
},

View File

@@ -136,6 +136,15 @@ Toolbox enables dynamic reloading by default. To disable, use the
`--disable-reload` flag.
{{< /notice >}}
#### Launching Toolbox UI
To launch Toolbox's interactive UI, use the `--ui` flag. This allows you to test tools and toolsets
with features such as authorized parameters. To learn more, visit [Toolbox UI](../../how-to/use-toolbox-ui/index.md).
```sh
./toolbox --ui
```
#### Homebrew Users
If you installed Toolbox using Homebrew, the `toolbox` binary is available in your system path. You can start the server with the same command:

View File

@@ -6,337 +6,9 @@ description: >
Create your AlloyDB database with MCP Toolbox.
---
This guide covers how to use [MCP Toolbox for Databases][toolbox] to create
AlloyDB clusters and instances from IDE enabling their E2E journey.
- [Cursor][cursor]
- [Windsurf][windsurf] (Codium)
- [Visual Studio Code][vscode] (Copilot)
- [Cline][cline] (VS Code extension)
- [Claude desktop][claudedesktop]
- [Claude code][claudecode]
- [Gemini CLI][geminicli]
- [Gemini Code Assist][geminicodeassist]
[toolbox]: https://github.com/googleapis/genai-toolbox
[cursor]: #configure-your-mcp-client
[windsurf]: #configure-your-mcp-client
[vscode]: #configure-your-mcp-client
[cline]: #configure-your-mcp-client
[claudedesktop]: #configure-your-mcp-client
[claudecode]: #configure-your-mcp-client
[geminicli]: #configure-your-mcp-client
[geminicodeassist]: #configure-your-mcp-client
## Before you begin
1. In the Google Cloud console, on the [project selector
page](https://console.cloud.google.com/projectselector2/home/dashboard),
select or create a Google Cloud project.
1. [Make sure that billing is enabled for your Google Cloud
project](https://cloud.google.com/billing/docs/how-to/verify-billing-enabled#confirm_billing_is_enabled_on_a_project).
## Install MCP Toolbox
1. Download the latest version of Toolbox as a binary. Select the [correct
binary](https://github.com/googleapis/genai-toolbox/releases) corresponding
to your OS and CPU architecture. You are required to use Toolbox version
V0.10.0+:
<!-- {x-release-please-start-version} -->
{{< tabpane persist=header >}}
{{< tab header="linux/amd64" lang="bash" >}}
curl -O https://storage.googleapis.com/genai-toolbox/v0.10.0/linux/amd64/toolbox
{{< /tab >}}
{{< tab header="darwin/arm64" lang="bash" >}}
curl -O https://storage.googleapis.com/genai-toolbox/v0.10.0/darwin/arm64/toolbox
{{< /tab >}}
{{< tab header="darwin/amd64" lang="bash" >}}
curl -O https://storage.googleapis.com/genai-toolbox/v0.10.0/darwin/amd64/toolbox
{{< /tab >}}
{{< tab header="windows/amd64" lang="bash" >}}
curl -O https://storage.googleapis.com/genai-toolbox/v0.10.0/windows/amd64/toolbox.exe
{{< /tab >}}
{{< /tabpane >}}
<!-- {x-release-please-end} -->
1. Make the binary executable:
```bash
chmod +x toolbox
```
1. Verify the installation:
```bash
./toolbox --version
```
## Configure your MCP Client
{{< tabpane text=true >}}
{{% tab header="Claude code" lang="en" %}}
1. Install [Claude
Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview).
1. Create a `.mcp.json` file in your project root if it doesn't exist.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
1. Restart Claude code to apply the new configuration.
{{% /tab %}}
{{% tab header="Claude desktop" lang="en" %}}
1. Open [Claude desktop](https://claude.ai/download) and navigate to Settings.
1. Under the Developer tab, tap Edit Config to open the configuration file.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
1. Restart Claude desktop.
1. From the new chat screen, you should see a hammer (MCP) icon appear with the
new MCP server available.
{{% /tab %}}
{{% tab header="Cline" lang="en" %}}
1. Open the [Cline](https://github.com/cline/cline) extension in VS Code and tap
the **MCP Servers** icon.
1. Tap Configure MCP Servers to open the configuration file.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
1. You should see a green active status after the server is successfully
connected.
{{% /tab %}}
{{% tab header="Cursor" lang="en" %}}
1. Create a `.cursor` directory in your project root if it doesn't exist.
1. Create a `.cursor/mcp.json` file if it doesn't exist and open it.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
1. [Cursor](https://www.cursor.com/) and navigate to **Settings > Cursor
Settings > MCP**. You should see a green active status after the server is
successfully connected.
{{% /tab %}}
{{% tab header="Visual Studio Code (Copilot)" lang="en" %}}
1. Open [VS Code](https://code.visualstudio.com/docs/copilot/overview) and
create a `.vscode` directory in your project root if it doesn't exist.
1. Create a `.vscode/mcp.json` file if it doesn't exist and open it.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
{{% /tab %}}
{{% tab header="Windsurf" lang="en" %}}
1. Open [Windsurf](https://docs.codeium.com/windsurf) and navigate to the
Cascade assistant.
1. Tap on the hammer (MCP) icon, then Configure to open the configuration file.
1. Generate Access token to be used as API_KEY using `gcloud auth
print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
{{% /tab %}}
{{% tab header="Gemini CLI" lang="en" %}}
1. Install the [Gemini
CLI](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#quickstart).
1. In your working directory, create a folder named `.gemini`. Within it, create
a `settings.json` file.
1. Generate Access token to be used as API_KEY using `gcloud auth print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
{{% /tab %}}
{{% tab header="Gemini Code Assist" lang="en" %}}
1. Install the [Gemini Code
Assist](https://marketplace.visualstudio.com/items?itemName=Google.geminicodeassist)
extension in Visual Studio Code.
1. Enable Agent Mode in Gemini Code Assist chat.
1. In your working directory, create a folder named `.gemini`. Within it, create
a `settings.json` file.
1. Generate Access token to be used as API_KEY using `gcloud auth print-access-token`.
> **Note:** The lifetime of token is 1 hour.
1. Add the following configuration, replace the environment variables with your
values, and save:
```json
{
"mcpServers": {
"alloydb-admin": {
"command": "./PATH/TO/toolbox",
"args": ["--prebuilt", "alloydb-postgres-admin", "--stdio"],
"env": {
"API_KEY": "your-api-key"
}
}
}
}
```
{{% /tab %}}
{{< /tabpane >}}
## Use Tools
Your AI tool is now connected to AlloyDB using MCP. Try asking your AI assistant
to create a database, cluster or instance.
The following tools are available to the LLM:
1. **alloydb-create-cluster**: creates alloydb cluster
1. **alloydb-create-instance**: creates alloydb instance (PRIMARY, READ_POOL or SECONDARY)
1. **alloydb-get-operation**: polls on operations API until the operation is done.
{{< notice note >}}
Prebuilt tools are pre-1.0, so expect some tool changes between versions. LLMs
will adapt to the tools available, so this shouldn't affect most users.
{{< /notice >}}
## Connect to your Data
After setting up an AlloyDB cluster and instance, you can [connect your IDE to
the
database](https://cloud.google.com/alloydb/docs/pre-built-tools-with-mcp-toolbox).
<html>
<head>
<link rel="canonical" href="https://cloud.google.com/alloydb/docs/create-database-with-mcp-toolbox"/>
<meta http-equiv="refresh" content="0;url=https://cloud.google.com/alloydb/docs/create-database-with-mcp-toolbox"/>
</head>
</html>

View File

@@ -7,7 +7,7 @@ description: >
---
<html>
<head>
<link rel="canonical" href="https://cloud.google.com/alloydb/docs/pre-built-tools-with-mcp-toolbox"/>
<meta http-equiv="refresh" content="0;url=https://cloud.google.com/alloydb/docs/pre-built-tools-with-mcp-toolbox"/>
<link rel="canonical" href="https://cloud.google.com/alloydb/docs/connect-ide-using-mcp-toolbox"/>
<meta http-equiv="refresh" content="0;url=https://cloud.google.com/alloydb/docs/connect-ide-using-mcp-toolbox"/>
</head>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -0,0 +1,106 @@
---
title: "Toolbox UI"
type: docs
weight: 1
description: >
How to effectively use Toolbox UI.
---
Toolbox UI is a built-in web interface that allows users to visually inspect and test out configured resources such as tools and toolsets.
## Launching Toolbox UI
To launch Toolbox's interactive UI, use the `--ui` flag.
```sh
./toolbox --ui
```
Toolbox UI will be served from the same host and port as the Toolbox Server, with the `/ui` suffix. Once Toolbox
is launched, the following INFO log with Toolbox UI's url will be shown:
```bash
INFO "Toolbox UI is up and running at: http://localhost:5000/ui"
```
## Navigating the Tools Page
The tools page shows all tools loaded from your configuration file. This corresponds to the default toolset (represented by an empty string). Each tool's name on this page will exactly match its name in the configuration
file.
To view details for a specific tool, click on the tool name. The main content area will be populated
with the tool name, description, and available parameters.
![Tools Page](./tools.png)
### Invoking a Tool
1. Click on a Tool
2. Enter appropriate parameters in each parameter field
3. Click "Run Tool"
4. Done! Your results will appear in the response field
5. (Optional) Uncheck "Prettify JSON" to format the response as plain text
![Run Tool Demo GIF](./run-tool.gif)
### Optional Parameters
Toolbox allows users to add [optional parameters](../../resources/tools/#basic-parameters) with or without a default value.
To exclude a parameter, uncheck the box to the right of an associated parameter, and that parameter will not be
included in the request body. If the parameter is not sent, Toolbox will either use it as `nil` value or the `default` value, if configured. If the parameter is required, Toolbox will throw an error.
When the box is checked, parameter will be sent exactly as entered in the response field (e.g. empty string).
![Optional Parameter checked example](./optional-param-checked.png)
![Optional Parameter unchecked example](./optional-param-unchecked.png)
### Editing Headers
To edit headers, press the "Edit Headers" button to display the header modal. Within this modal,
users can make direct edits by typing into the header's text area.
Toolbox UI validates that the headers are in correct JSON format. Other header-related errors (e.g.,
incorrect header names or values required by the tool) will be reported in the Response section
after running the tool.
![Edit Headers](./edit-headers.png)
#### Google OAuth
Currently, Toolbox supports Google OAuth 2.0 as an AuthService, which allows tools to utilize
authorized parameters. When a tool uses an authorized parameter, the parameter will be displayed
but not editable, as it will be populated from the authentication token.
To provide the token, add your Google OAuth ID Token to the request header using the "Edit Headers"
button and modal described above. The key should be the name of your AuthService as defined in
your tool configuration file, suffixed with `_token`. The value should be your ID token as a string.
1. Select a tool that requires [authenticated parameters]()
2. The auth parameter's text field is greyed out. This is because it cannot be entered manually and will
be parsed from the resolved auth token
3. To update request headers with the token, select "Edit Headers"
4. Checkout the dropdown "How to extract Google OAuth ID Token manually" for guidance on retrieving ID token
5. Paste the request header
6. Click "Save"
7. Click "Run Tool"
```json
{
"Content-Type": "application/json",
"my-google-auth_token": "YOUR_ID_TOKEN_HERE"
}
```
![Using Authenticated Parameter GIF](./edit-headers.gif)
## Navigating the Toolsets Page
Through the toolsets page, users can search for a specific toolset to retrieve tools from. Simply
enter the toolset name in the search bar, and press "Enter" to retrieve the associated tools.
If the toolset name is not defined within the tools configuration file, an error message will be
displayed.
![Toolsets Page](./toolsets.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -35,10 +35,213 @@ You can use the following system prompt as "Custom Instructions" in your client
application.
```
Whenever you will receive response from dataplex_search_entries tool decide what do to by following these steps:
# Objective
Your primary objective is to help discover, organize and manage metadata related to data assets.
# Tone and Style
1. Adopt the persona of a senior subject matter expert
2. Your communication style must be:
1. Concise: Always favor brevity.
2. Direct: Avoid greetings (e.g., "Hi there!", "Certainly!"). Get straight to the point.
Example (Incorrect): Hi there! I see that you are looking for...
Example (Correct): This problem likely stems from...
3. Do not reiterate or summarize the question in the answer.
4. Crucially, always convey a tone of uncertainty and caution. Since you are interpreting metadata and have no way to externally verify your answers, never express complete confidence. Frame your responses as interpretations based solely on the provided metadata. Use a suggestive tone, not a prescriptive one:
Example (Correct): "The entry describes..."
Example (Correct): "According to catalog,..."
Example (Correct): "Based on the metadata,..."
Example (Correct): "Based on the search results,..."
5. Do not make assumptions
# Data Model
## Entries
Entry represents a specific data asset. Entry acts as a metadata record for something that is managed by Catalog, such as:
- A BigQuery table or dataset
- A Cloud Storage bucket or folder
- An on-premises SQL table
## Aspects
While the Entry itself is a container, the rich descriptive information about the asset (e.g., schema, data types, business descriptions, classifications) is stored in associated components called Aspects. Aspects are created based on pre-defined blueprints known as Aspect Types.
## Aspect Types
Aspect Type is a reusable template that defines the schema for a set of metadata fields. Think of an Aspect Type as a structure for the kind of metadata that is organized in the catalog within the Entry.
Examples:
- projects/dataplex-types/locations/global/aspectTypes/analytics-hub-exchange
- projects/dataplex-types/locations/global/aspectTypes/analytics-hub
- projects/dataplex-types/locations/global/aspectTypes/analytics-hub-listing
- projects/dataplex-types/locations/global/aspectTypes/bigquery-connection
- projects/dataplex-types/locations/global/aspectTypes/bigquery-data-policy
- projects/dataplex-types/locations/global/aspectTypes/bigquery-dataset
- projects/dataplex-types/locations/global/aspectTypes/bigquery-model
- projects/dataplex-types/locations/global/aspectTypes/bigquery-policy
- projects/dataplex-types/locations/global/aspectTypes/bigquery-routine
- projects/dataplex-types/locations/global/aspectTypes/bigquery-row-access-policy
- projects/dataplex-types/locations/global/aspectTypes/bigquery-table
- projects/dataplex-types/locations/global/aspectTypes/bigquery-view
- projects/dataplex-types/locations/global/aspectTypes/cloud-bigtable-instance
- projects/dataplex-types/locations/global/aspectTypes/cloud-bigtable-table
- projects/dataplex-types/locations/global/aspectTypes/cloud-spanner-database
- projects/dataplex-types/locations/global/aspectTypes/cloud-spanner-instance
- projects/dataplex-types/locations/global/aspectTypes/cloud-spanner-table
- projects/dataplex-types/locations/global/aspectTypes/cloud-spanner-view
- projects/dataplex-types/locations/global/aspectTypes/cloudsql-database
- projects/dataplex-types/locations/global/aspectTypes/cloudsql-instance
- projects/dataplex-types/locations/global/aspectTypes/cloudsql-schema
- projects/dataplex-types/locations/global/aspectTypes/cloudsql-table
- projects/dataplex-types/locations/global/aspectTypes/cloudsql-view
- projects/dataplex-types/locations/global/aspectTypes/contacts
- projects/dataplex-types/locations/global/aspectTypes/dataform-code-asset
- projects/dataplex-types/locations/global/aspectTypes/dataform-repository
- projects/dataplex-types/locations/global/aspectTypes/dataform-workspace
- projects/dataplex-types/locations/global/aspectTypes/dataproc-metastore-database
- projects/dataplex-types/locations/global/aspectTypes/dataproc-metastore-service
- projects/dataplex-types/locations/global/aspectTypes/dataproc-metastore-table
- projects/dataplex-types/locations/global/aspectTypes/data-product
- projects/dataplex-types/locations/global/aspectTypes/data-quality-scorecard
- projects/dataplex-types/locations/global/aspectTypes/external-connection
- projects/dataplex-types/locations/global/aspectTypes/overview
- projects/dataplex-types/locations/global/aspectTypes/pubsub-topic
- projects/dataplex-types/locations/global/aspectTypes/schema
- projects/dataplex-types/locations/global/aspectTypes/sensitive-data-protection-job-result
- projects/dataplex-types/locations/global/aspectTypes/sensitive-data-protection-profile
- projects/dataplex-types/locations/global/aspectTypes/sql-access
- projects/dataplex-types/locations/global/aspectTypes/storage-bucket
- projects/dataplex-types/locations/global/aspectTypes/storage-folder
- projects/dataplex-types/locations/global/aspectTypes/storage
- projects/dataplex-types/locations/global/aspectTypes/usage
## Entry Types
Every Entry must conform to an Entry Type. The Entry Type acts as a template, defining the structure, required aspects, and constraints for Entries of that type.
Examples:
- projects/dataplex-types/locations/global/entryTypes/analytics-hub-exchange
- projects/dataplex-types/locations/global/entryTypes/analytics-hub-listing
- projects/dataplex-types/locations/global/entryTypes/bigquery-connection
- projects/dataplex-types/locations/global/entryTypes/bigquery-data-policy
- projects/dataplex-types/locations/global/entryTypes/bigquery-dataset
- projects/dataplex-types/locations/global/entryTypes/bigquery-model
- projects/dataplex-types/locations/global/entryTypes/bigquery-routine
- projects/dataplex-types/locations/global/entryTypes/bigquery-row-access-policy
- projects/dataplex-types/locations/global/entryTypes/bigquery-table
- projects/dataplex-types/locations/global/entryTypes/bigquery-view
- projects/dataplex-types/locations/global/entryTypes/cloud-bigtable-instance
- projects/dataplex-types/locations/global/entryTypes/cloud-bigtable-table
- projects/dataplex-types/locations/global/entryTypes/cloud-spanner-database
- projects/dataplex-types/locations/global/entryTypes/cloud-spanner-instance
- projects/dataplex-types/locations/global/entryTypes/cloud-spanner-table
- projects/dataplex-types/locations/global/entryTypes/cloud-spanner-view
- projects/dataplex-types/locations/global/entryTypes/cloudsql-mysql-database
- projects/dataplex-types/locations/global/entryTypes/cloudsql-mysql-instance
- projects/dataplex-types/locations/global/entryTypes/cloudsql-mysql-table
- projects/dataplex-types/locations/global/entryTypes/cloudsql-mysql-view
- projects/dataplex-types/locations/global/entryTypes/cloudsql-postgresql-database
- projects/dataplex-types/locations/global/entryTypes/cloudsql-postgresql-instance
- projects/dataplex-types/locations/global/entryTypes/cloudsql-postgresql-schema
- projects/dataplex-types/locations/global/entryTypes/cloudsql-postgresql-table
- projects/dataplex-types/locations/global/entryTypes/cloudsql-postgresql-view
- projects/dataplex-types/locations/global/entryTypes/cloudsql-sqlserver-database
- projects/dataplex-types/locations/global/entryTypes/cloudsql-sqlserver-instance
- projects/dataplex-types/locations/global/entryTypes/cloudsql-sqlserver-schema
- projects/dataplex-types/locations/global/entryTypes/cloudsql-sqlserver-table
- projects/dataplex-types/locations/global/entryTypes/cloudsql-sqlserver-view
- projects/dataplex-types/locations/global/entryTypes/dataform-code-asset
- projects/dataplex-types/locations/global/entryTypes/dataform-repository
- projects/dataplex-types/locations/global/entryTypes/dataform-workspace
- projects/dataplex-types/locations/global/entryTypes/dataproc-metastore-database
- projects/dataplex-types/locations/global/entryTypes/dataproc-metastore-service
- projects/dataplex-types/locations/global/entryTypes/dataproc-metastore-table
- projects/dataplex-types/locations/global/entryTypes/pubsub-topic
- projects/dataplex-types/locations/global/entryTypes/storage-bucket
- projects/dataplex-types/locations/global/entryTypes/storage-folder
- projects/dataplex-types/locations/global/entryTypes/vertexai-dataset
- projects/dataplex-types/locations/global/entryTypes/vertexai-feature-group
- projects/dataplex-types/locations/global/entryTypes/vertexai-feature-online-store
## Entry Groups
Entries are organized within Entry Groups, which are logical groupings of Entries. An Entry Group acts as a namespace for its Entries.
## Entry Links
Entries can be linked together using EntryLinks to represent relationships between data assets (e.g. foreign keys).
# Tool instructions
## Tool: dataplex_search_entries
## General
- Do not try to search within search results on your own.
- Do not fetch multiple pages of results unless explicitly asked.
## Search syntax
### Simple search
In its simplest form, a search query consists of a single predicate. Such a predicate can match several pieces of metadata:
- A substring of a name, display name, or description of a resource
- A substring of the type of a resource
- A substring of a column name (or nested column name) in the schema of a resource
- A substring of a project ID
- A string from an overview description
For example, the predicate foo matches the following resources:
- Resource with the name foo.bar
- Resource with the display name Foo Bar
- Resource with the description This is the foo script
- Resource with the exact type foo
- Column foo_bar in the schema of a resource
- Nested column foo_bar in the schema of a resource
- Project prod-foo-bar
- Resource with an overview containing the word foo
### Qualified predicates
You can qualify a predicate by prefixing it with a key that restricts the matching to a specific piece of metadata:
- An equal sign (=) restricts the search to an exact match.
- A colon (:) after the key matches the predicate to either a substring or a token within the value in the search results.
Tokenization splits the stream of text into a series of tokens, with each token usually corresponding to a single word. For example:
- name:foo selects resources with names that contain the foo substring, like foo1 and barfoo.
- description:foo selects resources with the foo token in the description, like bar and foo.
- location=foo matches resources in a specified location with foo as the location name.
The predicate keys type, system, location, and orgid support only the exact match (=) qualifier, not the substring qualifier (:). For example, type=foo or orgid=number.
Search syntax supports the following qualifiers:
- "name:x" - Matches x as a substring of the resource ID.
- "displayname:x" - Match x as a substring of the resource display name.
- "column:x" - Matches x as a substring of the column name (or nested column name) in the schema of the resource.
- "description:x" - Matches x as a token in the resource description.
- "label:bar" - Matches BigQuery resources that have a label (with some value) and the label key has bar as a substring.
- "label=bar" - Matches BigQuery resources that have a label (with some value) and the label key equals bar as a string.
- "label:bar:x" - Matches x as a substring in the value of a label with a key bar attached to a BigQuery resource.
- "label=foo:bar" - Matches BigQuery resources where the key equals foo and the key value equals bar.
- "label.foo=bar" - Matches BigQuery resources where the key equals foo and the key value equals bar.
- "label.foo" - Matches BigQuery resources that have a label whose key equals foo as a string.
- "type=TYPE" - Matches resources of a specific entry type or its type alias.
- "projectid:bar" - Matches resources within Google Cloud projects that match bar as a substring in the ID.
- "parent:x" - Matches x as a substring of the hierarchical path of a resource. The parent path is a fully_qualified_name of the parent resource.
- "orgid=number" - Matches resources within a Google Cloud organization with the exact ID value of the number.
- "system=SYSTEM" - Matches resources from a specified system. For example, system=bigquery matches BigQuery resources.
- "location=LOCATION" - Matches resources in a specified location with an exact name. For example, location=us-central1 matches assets hosted in Iowa. BigQuery Omni assets support this qualifier by using the BigQuery Omni location name. For example, location=aws-us-east-1 matches BigQuery Omni assets in Northern Virginia.
- "createtime" -
Finds resources that were created within, before, or after a given date or time. For example "createtime:2019-01-01" matches resources created on 2019-01-01.
- "updatetime" - Finds resources that were updated within, before, or after a given date or time. For example "updatetime>2019-01-01" matches resources updated after 2019-01-01.
- "fully_qualified_name:x" - Matches x as a substring of fully_qualified_name.
- "fully_qualified_name=x" - Matches x as fully_qualified_name.
### Logical operators
A query can consist of several predicates with logical operators. If you don't specify an operator, logical AND is implied. For example, foo bar returns resources that match both predicate foo and predicate bar.
Logical AND and logical OR are supported. For example, foo OR bar.
You can negate a predicate with a - (hyphen) or NOT prefix. For example, -name:foo returns resources with names that don't match the predicate foo.
Logical operators aren't case-sensitive. For example, both or and OR are acceptable.
### Request
1. Always try to rewrite the prompt using search syntax.
### Response
1. If there are multiple search results found
1.1. Present the list of search results
1.2. Format the output in nested ordered list, for example:
1. Present the list of search results
2. Format the output in nested ordered list, for example:
Given
```
{
@@ -75,14 +278,19 @@ Whenever you will receive response from dataplex_search_entries tool decide what
- location: us-central1
- description: Table contains list of best customers.
```
1.3. Ask to select one of the presented search results
3. Ask to select one of the presented search results
2. If there is only one search result found
2.1. Present the search result immediately.
1. Present the search result immediately.
3. If there are no search result found
3.1. Explain that no search result was found
3.2. Suggest to provide a more specific search query.
1. Explain that no search result was found
2. Suggest to provide a more specific search query.
Do not try to search within search results on your own.
## Tool: dataplex_lookup_entry
### Request
1. Always try to limit the size of the response by specifying `aspect_types` parameter. Make sure to include to select view=CUSTOM when using aspect_types parameter.
2. If you do not know the name of the entry, use `dataplex_search_entries` tool
### Response
1. Unless asked for a specific aspect, respond with all aspects attached to the entry.
```
## Reference
@@ -90,4 +298,4 @@ Do not try to search within search results on your own.
| **field** | **type** | **required** | **description** |
|-----------|:--------:|:------------:|----------------------------------------------------------------------------------|
| kind | string | true | Must be "dataplex". |
| project | string | true | Id of the GCP project used for quota and billing purposes (e.g. "my-project-id").|
| project | string | true | ID of the GCP project used for quota and billing purposes (e.g. "my-project-id").|

View File

@@ -0,0 +1,81 @@
---
title: "TiDB"
type: docs
weight: 1
description: >
TiDB is a distributed SQL database that combines the best of traditional RDBMS and NoSQL databases.
---
## About
[TiDB][tidb-docs] is an open-source distributed SQL database that supports Hybrid Transactional and Analytical Processing (HTAP) workloads. It is MySQL-compatible and features horizontal scalability, strong consistency, and high availability.
[tidb-docs]: https://docs.pingcap.com/tidb/stable
## Requirements
### Database User
This source uses standard MySQL protocol authentication. You will need to [create a TiDB user][tidb-users] to login to the database with.
For TiDB Cloud users, you can create database users through the TiDB Cloud console.
[tidb-users]: https://docs.pingcap.com/tidb/stable/user-account-management
## SSL Configuration
- TiDB Cloud
For TiDB Cloud instances, SSL is automatically enabled when the hostname matches the TiDB Cloud pattern (`gateway*.*.*.tidbcloud.com`). You don't need to explicitly set `ssl: true` for TiDB Cloud connections.
- Self-Hosted TiDB
For self-hosted TiDB instances, you can optionally enable SSL by setting `ssl: true` in your configuration.
## Example
- TiDB Cloud
```yaml
sources:
my-tidb-cloud-source:
kind: tidb
host: gateway01.us-west-2.prod.aws.tidbcloud.com
port: 4000
database: my_db
user: ${TIDB_USERNAME}
password: ${TIDB_PASSWORD}
# SSL is automatically enabled for TiDB Cloud
```
- Self-Hosted TiDB
```yaml
sources:
my-tidb-source:
kind: tidb
host: 127.0.0.1
port: 4000
database: my_db
user: ${TIDB_USERNAME}
password: ${TIDB_PASSWORD}
# ssl: true # Optional: enable SSL for secure connections
```
{{< notice tip >}}
Use environment variable replacement with the format ${ENV_NAME}
instead of hardcoding your secrets into the configuration file.
{{< /notice >}}
## Reference
| **field** | **type** | **required** | **description** |
|-----------|:--------:|:------------:|--------------------------------------------------------------------------------------------|
| kind | string | true | Must be "tidb". |
| host | string | true | IP address or hostname to connect to (e.g. "127.0.0.1" or "gateway01.*.tidbcloud.com"). |
| port | string | true | Port to connect to (typically "4000" for TiDB). |
| database | string | true | Name of the TiDB database to connect to (e.g. "my_db"). |
| user | string | true | Name of the TiDB user to connect as (e.g. "my-tidb-user"). |
| password | string | true | Password of the TiDB user (e.g. "my-password"). |
| ssl | boolean | false | Whether to use SSL/TLS encryption. Automatically enabled for TiDB Cloud instances. |

View File

@@ -0,0 +1,60 @@
---
title: "dataplex-lookup-entry"
type: docs
weight: 1
description: >
A "dataplex-lookup-entry" tool returns details of a particular entry in Dataplex Catalog.
aliases:
- /resources/tools/dataplex-lookup-entry
---
## About
A `dataplex-lookup-entry` tool returns details of a particular entry in Dataplex Catalog.
It's compatible with the following sources:
- [dataplex](../sources/dataplex.md)
`dataplex-lookup-entry` takes a required `name` parameter which contains the project and location to which the request should be attributed in the following form: projects/{project}/locations/{location} and also a required `entry` parameter which is the resource name of the entry in the following form: projects/{project}/locations/{location}/entryGroups/{entryGroup}/entries/{entry}. It also optionally accepts following parameters:
- `view` - View to control which parts of an entry the service should return. It takes integer values from 1-4 corresponding to type of view - BASIC, FULL, CUSTOM, ALL
- `aspectTypes` - Limits the aspects returned to the provided aspect types in the format `projects/{project}/locations/{location}/aspectTypes/{aspectType}`. It only works for CUSTOM view.
- `paths` - Limits the aspects returned to those associated with the provided paths within the Entry. It only works for CUSTOM view.
## Requirements
### IAM Permissions
Dataplex uses [Identity and Access Management (IAM)][iam-overview] to control
user and group access to Dataplex resources. Toolbox will use your
[Application Default Credentials (ADC)][adc] to authorize and authenticate when
interacting with [Dataplex][dataplex-docs].
In addition to [setting the ADC for your server][set-adc], you need to ensure
the IAM identity has been given the correct IAM permissions for the tasks you
intend to perform. See [Dataplex Universal Catalog IAM permissions][iam-permissions]
and [Dataplex Universal Catalog IAM roles][iam-roles] for more information on
applying IAM permissions and roles to an identity.
[iam-overview]: https://cloud.google.com/dataplex/docs/iam-and-access-control
[adc]: https://cloud.google.com/docs/authentication#adc
[set-adc]: https://cloud.google.com/docs/authentication/provide-credentials-adc
[iam-permissions]: https://cloud.google.com/dataplex/docs/iam-permissions
[iam-roles]: https://cloud.google.com/dataplex/docs/iam-roles
## Example
```yaml
tools:
lookup_entry:
kind: dataplex-lookup-entry
source: my-dataplex-source
description: Use this tool to retrieve a specific entry in Dataplex Catalog.
```
## Reference
| **field** | **type** | **required** | **description** |
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
| kind | string | true | Must be "dataplex-lookup-entry". |
| source | string | true | Name of the source the tool should execute on. |
| description | string | true | Description of the tool that is passed to the LLM. |

View File

@@ -0,0 +1,7 @@
---
title: "TiDB"
type: docs
weight: 1
description: >
Tools that work with TiDB Sources, such as TiDB Cloud and self-hosted TiDB.
---

View File

@@ -0,0 +1,41 @@
---
title: "tidb-execute-sql"
type: docs
weight: 1
description: >
A "tidb-execute-sql" tool executes a SQL statement against a TiDB
database.
aliases:
- /resources/tools/tidb-execute-sql
---
## About
A `tidb-execute-sql` tool executes a SQL statement against a TiDB
database. It's compatible with the following source:
- [tidb](../sources/tidb.md)
`tidb-execute-sql` takes one input parameter `sql` and run the sql
statement against the `source`.
> **Note:** This tool is intended for developer assistant workflows with
> human-in-the-loop and shouldn't be used for production agents.
## Example
```yaml
tools:
execute_sql_tool:
kind: tidb-execute-sql
source: my-tidb-instance
description: Use this tool to execute sql statement.
```
## Reference
| **field** | **type** | **required** | **description** |
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
| kind | string | true | Must be "tidb-execute-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. |

View File

@@ -0,0 +1,105 @@
---
title: "tidb-sql"
type: docs
weight: 1
description: >
A "tidb-sql" tool executes a pre-defined SQL statement against a TiDB
database.
aliases:
- /resources/tools/tidb-sql
---
## About
A `tidb-sql` tool executes a pre-defined SQL statement against a TiDB
database. It's compatible with the following source:
- [tidb](../sources/tidb.md)
The specified SQL statement is executed as a [prepared statement][tidb-prepare],
and expects parameters in the SQL query to be in the form of placeholders `?`.
[tidb-prepare]: https://docs.pingcap.com/tidb/stable/sql-prepared-plan-cache
## 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: tidb-sql
source: my-tidb-instance
statement: |
SELECT * FROM flights
WHERE airline = ?
AND flight_number = ?
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: tidb-sql
source: my-tidb-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 "tidb-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. |

3
go.mod
View File

@@ -20,6 +20,7 @@ require (
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/httplog/v2 v2.1.1
github.com/go-chi/render v1.0.3
github.com/go-goquery/goquery v1.0.1
github.com/go-playground/validator/v10 v10.27.0
github.com/go-sql-driver/mysql v1.9.3
github.com/goccy/go-yaml v1.18.0
@@ -49,6 +50,7 @@ require (
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/duckdb/duckdb-go-bindings v0.1.17 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12 // indirect
@@ -75,6 +77,7 @@ require (
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/apache/arrow-go/v18 v18.4.0 // indirect
github.com/apache/arrow/go/v15 v15.0.2 // indirect

40
go.sum
View File

@@ -665,6 +665,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
@@ -674,6 +676,8 @@ github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGW
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0=
github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14=
@@ -806,6 +810,8 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-goquery/goquery v1.0.1 h1:kpchVA1LdOFWdRpkDPESVdlb1JQI6ixsJ5MiNUITO7U=
github.com/go-goquery/goquery v1.0.1/go.mod h1:W5s8OWbqWf6lG0LkXWBeh7U1Y/X5XTI0Br65MHF8uJk=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -910,6 +916,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -1226,6 +1233,10 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1287,6 +1298,9 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1346,6 +1360,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1395,6 +1414,10 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1477,8 +1500,14 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -1487,6 +1516,11 @@ golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1503,6 +1537,10 @@ golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1577,6 +1615,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -7,9 +7,13 @@ tools:
dataplex_search_entries:
kind: dataplex-search-entries
source: dataplex-source
description: |
Use this tool to search for entries in Dataplex Catalog that represent data assets (e.g. tables, views, models) based on the provided search query.
description: Use this tool to search for entries in Dataplex Catalog based on the provided search query.
dataplex_lookup_entry:
kind: dataplex-lookup-entry
source: dataplex-source
description: Use this tool to retrieve a specific entry from Dataplex Catalog.
toolsets:
dataplex-tools:
- dataplex_search_entries
- dataplex_search_entries
- dataplex_lookup_entry

View File

@@ -55,6 +55,8 @@ type ServerConfig struct {
Stdio bool
// DisableReload indicates if the user has disabled dynamic reloading for Toolbox.
DisableReload bool
// UI indicates if Toolbox UI endpoints (/ui) are available
UI bool
}
type logFormat string

View File

@@ -214,7 +214,7 @@ func (s *stdioSession) write(ctx context.Context, response any) error {
func mcpRouter(s *Server) (chi.Router, error) {
r := chi.NewRouter()
r.Use(middleware.AllowContentType("application/json"))
r.Use(middleware.AllowContentType("application/json", "application/json-rpc", "application/jsonrequest"))
r.Use(middleware.StripSlashes)
r.Use(render.SetContentType(render.ContentTypeJSON))

View File

@@ -330,6 +330,13 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
return nil, err
}
r.Mount("/mcp", mcpR)
if cfg.UI {
webR, err := webRouter()
if err != nil {
return nil, err
}
r.Mount("/ui", webR)
}
// default endpoint for validating server is running
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("🧰 Hello, World! 🧰"))

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -0,0 +1,615 @@
:root {
--toolbox-blue: #4285f4;
--text-primary-gray: #444444;
--text-secondary-gray: #6e6e6e;
--button-primary: var(--toolbox-blue);
--button-secondary: #616161;
}
body {
display: flex;
height: 100vh;
margin: 0;
font-family: 'Trebuchet MS';
background-color: #f8f9fa;
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
#navbar-container {
flex: 0 0 250px;
height: 100%;
position: relative;
z-index: 10;
}
#main-content-container {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow-x: hidden;
}
.left-nav {
background-color: #fff;
box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
padding: 15px;
align-items: center;
width: 100%;
height: 100%;
z-index: 3;
ul {
font-family: 'Verdana';
list-style: none;
padding: 0;
margin: 0;
width: 100%;
li {
margin-bottom: 5px;
a {
display: flex;
align-items: center;
padding: 12px;
text-decoration: none;
color: #333;
border-radius: 0;
&:hover {
background-color: #e9e9e9;
border-radius: 35px;
}
&.active {
background-color: #d0d0d0;
font-weight: bold;
border-radius: 35px;
}
}
}
}
}
.second-nav {
flex: 0 0 250px;
background-color: #fff;
box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15);
z-index: 2;
display: flex;
flex-direction: column;
padding: 15px;
align-items: center;
position: relative;
}
.nav-logo {
width: 90%;
margin-bottom: 40px;
flex-shrink: 0;
img {
max-width: 100%;
height: auto;
display: block;
}
}
.main-content-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow-x: hidden;
}
.top-bar {
background-color: #fff;
padding: 30px 30px;
display: flex;
justify-content: flex-end;
align-items: center;
border-bottom: 1px solid #eee;
}
.content {
padding: 20px;
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
color: white;
border: none;
border-radius: 30px;
font: inherit;
font-size: 1em;
font-weight: bolder;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.btn--run {
background-color: var(--button-primary);
}
.btn--editHeaders {
background-color: var(--button-secondary)
}
.btn--saveHeaders {
background-color: var(--button-primary)
}
.btn--closeHeaders {
background-color: var(--button-secondary)
}
.second-nav-choice {
display: flex;
align-items: center;
padding: 12px;
text-decoration: none;
color: #333;
background-color: transparent;
border: none;
border-radius: 0;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
font-size: inherit;
transition: background-color 0.1s ease-in-out, border-radius 0.1s ease-in-out;
&:hover {
background-color: #e9e9e9;
border-radius: 35px;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(208, 208, 208, 0.5);
}
&.active {
background-color: #d0d0d0;
font-weight: bold;
border-radius: 35px;
&:hover {
background-color: #d0d0d0;
}
}
}
#secondary-panel-content {
flex: 1;
overflow-y: auto;
width: 100%;
min-height: 0;
ul {
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
}
.tool-details-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
margin: 0 0 20px 0;
align-items: start;
flex-shrink: 0;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 15px;
}
.tool-execution-area {
display: flex;
flex-direction: column;
gap: 12px;
}
.tool-params {
background-color: #ffffff;
padding: 15px;
border-radius: 4px;
border: 1px solid #ddd;
h5 {
margin-bottom: 0;
}
}
.tool-box {
background-color: #ffffff;
padding: 15px;
border-radius: 4px;
border: 1px solid #eee;
h5 {
color: var(--toolbox-blue);
margin-top: 0;
font-weight: bold;
}
}
.params-header {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
padding-right: 6px;
font-weight: bold;
font-size: 0.9em;
color: var(--text-secondary-gray);
}
.params-disclaimer {
font-style: italic;
color: var(--text-secondary-gray);
font-size: 0.8em;
margin-bottom: 10px;
width: 100%;
word-wrap: break-word;
}
.param-item {
margin-bottom: 12px;
label {
display: block;
margin-bottom: 4px;
font-family: inherit;
}
&.disabled-param {
> label {
color: #888;
text-decoration: line-through;
}
.param-input-element {
background-color: #f5f5f5;
opacity: 0.6;
}
}
input[type="text"],
input[type="number"],
select,
textarea {
width: calc(100% - 12px);
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
}
input[type="checkbox"].param-input-element {
width: auto;
padding: 0;
border: initial;
border-radius: initial;
vertical-align: middle;
margin-right: 4px;
accent-color: var(--toolbox-blue);
flex-grow: 0;
}
}
.input-checkbox-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.param-input-element-container {
flex-grow: 1;
}
.param-input-element {
box-sizing: border-box;
}
.include-param-container {
display: flex;
align-items: center;
white-space: nowrap;
input[type="checkbox"] {
width: auto;
padding: 0;
border: initial;
border-radius: initial;
vertical-align: middle;
margin-right: 0;
accent-color: var(--toolbox-blue);
}
}
.include-param-container input[type="checkbox"] {
width: auto;
padding: 0;
border: initial;
border-radius: initial;
vertical-align: middle;
margin: 0;
accent-color: var(--toolbox-blue);
}
.checkbox-bool-label {
margin-left: 5px;
font-style: italic;
color: var(--text-primary-gray);
}
.checkbox-bool-label.disabled {
color: #aaa;
cursor: not-allowed;
}
.param-label-extras {
font-style: italic;
font-weight: lighter;
color: var(--text-secondary-gray);
}
.auth-param-input {
background-color: #e0e0e0;
cursor: not-allowed;
}
.run-button-container {
display: flex;
justify-content: flex-end;
gap: 20px;
}
.header-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
li {
margin-bottom: 10px;
}
.header-modal-content {
background-color: #fefefe;
margin: 10% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 50%;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
h5 {
margin-top: 0;
font-size: 1.2em;
}
.headers-textarea {
width: calc(100% - 16px);
padding: 8px;
font-family: monospace;
border: 1px solid #ccc;
border-radius: 4px;
min-height: 150px;
}
.header-modal-actions {
display: flex;
justify-content: center;
gap: 30px;
width: 100%;
}
.auth-token-details {
width: 100%;
max-width: calc(100% - 16px);
margin-left: 8px;
margin-right: 8px;
summary {
cursor: pointer;
text-align: left;
padding: 5px 0;
}
.auth-token-content {
padding: 10px;
border: 1px solid #eee;
margin-top: 5px;
background-color: #f9f9f9;
text-align: left;
max-width: 100%;
overflow-wrap: break-word;
.auth-tab-group {
display: flex;
border-bottom: 1px solid #ccc;
margin-bottom: 10px;
}
.auth-tab-picker {
padding: 8px 12px;
cursor: pointer;
border: 1px solid transparent;
border-bottom: 1px solid transparent;
margin-bottom: -1px;
background-color: #f0f0f0;
&.active {
background-color: #fff;
border-color: #ccc;
border-bottom-color: #fff;
font-weight: bold;
}
}
.auth-tab-content {
display: none;
overflow-wrap: break-word;
word-wrap: break-word;
max-width: 100%;
&.active {
display: block;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-x: auto;
background-color: #f5f5f5;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 100%;
code {
display: block;
word-wrap: break-word;
color: inherit;
}
}
}
}
}
}
}
.tool-response {
margin: 20px 0 0 0;
textarea {
width: 100%;
min-height: 150px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
}
}
.search-container {
display: flex;
width: 100%;
margin-bottom: 15px;
#toolset-search-input {
flex-grow: 1;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 20px 0 0 20px;
border-right: none;
font-family: inherit;
font-size: 0.9em;
color: var(--text-primary-gray);
&:focus {
outline: none;
border-color: var(--toolbox-blue);
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
}
&::placeholder {
color: var(--text-secondary-gray);
}
}
#toolset-search-button {
padding: 10px 15px;
border: 1px solid var(--button-primary);
background-color: var(--button-primary);
color: white;
border-radius: 0 20px 20px 0;
cursor: pointer;
font-family: inherit;
font-size: 0.9em;
font-weight: bold;
transition: opacity 0.2s ease-in-out;
flex-shrink: 0;
line-height: 1;
&:hover {
opacity: 0.8;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
}
}
}
.source-details-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.source-detail-item {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #eee;
align-items: start;
&:last-child {
border-bottom: none;
}
& .key {
font-weight: bold;
color: #7d2f2f;
padding-right: 8px;
}
& .value{
color: #0000FF;
word-break: break-all;
}
&:nth-child(even) {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toolbox UI</title>
<link rel="stylesheet" href="/ui/css/style.css">
</head>
<body>
<div id="navbar-container" data-active-nav=""></div>
<div id="main-content-container"></div>
<script src="/ui/js/navbar.js"></script>
<script src="/ui/js/mainContent.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const navbarContainer = document.getElementById('navbar-container');
const activeNav = navbarContainer.getAttribute('data-active-nav');
renderNavbar('navbar-container', activeNav);
renderMainContent('main-content-container', '')
});
</script>
</body>
</html>

View File

@@ -0,0 +1,173 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { renderToolInterface } from "./toolDisplay.js";
let toolDetailsAbortController = null;
/**
* Fetches a toolset from the /api/toolset endpoint and initiates creating the tool list.
* @param {!HTMLElement} secondNavContent The HTML element where the tool list will be rendered.
* @param {!HTMLElement} toolDisplayArea The HTML element where the details of a selected tool will be displayed.
* @param {string} toolsetName The name of the toolset to load (empty string loads all tools).
* @returns {!Promise<void>} A promise that resolves when the tools are loaded and rendered, or rejects on error.
*/
export async function loadTools(secondNavContent, toolDisplayArea, toolsetName) {
secondNavContent.innerHTML = '<p>Fetching tools...</p>';
try {
const response = await fetch(`/api/toolset/${toolsetName}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const apiResponse = await response.json();
renderToolList(apiResponse, secondNavContent, toolDisplayArea);
} catch (error) {
console.error('Failed to load tools:', error);
secondNavContent.innerHTML = `<p class="error">Failed to load tools: <pre><code>${error}</code></pre></p>`;
}
}
/**
* Renders the list of tools as buttons within the provided HTML element.
* @param {?{tools: ?Object<string,*>} } apiResponse The API response object containing the tools.
* @param {!HTMLElement} secondNavContent The HTML element to render the tool list into.
* @param {!HTMLElement} toolDisplayArea The HTML element for displaying tool details (passed to event handlers).
*/
function renderToolList(apiResponse, secondNavContent, toolDisplayArea) {
secondNavContent.innerHTML = '';
if (!apiResponse || typeof apiResponse.tools !== 'object' || apiResponse.tools === null) {
console.error('Error: Expected an object with a "tools" property, but received:', apiResponse);
secondNavContent.textContent = 'Error: Invalid response format from toolset API.';
return;
}
const toolsObject = apiResponse.tools;
const toolNames = Object.keys(toolsObject);
if (toolNames.length === 0) {
secondNavContent.textContent = 'No tools found.';
return;
}
const ul = document.createElement('ul');
toolNames.forEach(toolName => {
const li = document.createElement('li');
const button = document.createElement('button');
button.textContent = toolName;
button.dataset.toolname = toolName;
button.classList.add('second-nav-choice');
button.addEventListener('click', (event) => handleToolClick(event, secondNavContent, toolDisplayArea));
li.appendChild(button);
ul.appendChild(li);
});
secondNavContent.appendChild(ul);
}
/**
* Handles the click event on a tool button.
* @param {!Event} event The click event object.
* @param {!HTMLElement} secondNavContent The parent element containing the tool buttons.
* @param {!HTMLElement} toolDisplayArea The HTML element where tool details will be shown.
*/
function handleToolClick(event, secondNavContent, toolDisplayArea) {
const toolName = event.target.dataset.toolname;
if (toolName) {
const currentActive = secondNavContent.querySelector('.second-nav-choice.active');
if (currentActive) {
currentActive.classList.remove('active');
}
event.target.classList.add('active');
fetchToolDetails(toolName, toolDisplayArea);
}
}
/**
* Fetches details for a specific tool /api/tool endpoint.
* It aborts any previous in-flight request for tool details to stop race condition.
* @param {string} toolName The name of the tool to fetch details for.
* @param {!HTMLElement} toolDisplayArea The HTML element to display the tool interface in.
* @returns {!Promise<void>} A promise that resolves when the tool details are fetched and rendered, or rejects on error.
*/
async function fetchToolDetails(toolName, toolDisplayArea) {
if (toolDetailsAbortController) {
toolDetailsAbortController.abort();
console.debug("Aborted previous tool fetch.");
}
toolDetailsAbortController = new AbortController();
const signal = toolDetailsAbortController.signal;
toolDisplayArea.innerHTML = '<p>Loading tool details...</p>';
try {
const response = await fetch(`/api/tool/${encodeURIComponent(toolName)}`, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const apiResponse = await response.json();
if (!apiResponse.tools || !apiResponse.tools[toolName]) {
throw new Error(`Tool "${toolName}" data not found in API response.`);
}
const toolObject = apiResponse.tools[toolName];
console.debug("Received tool object: ", toolObject)
const toolInterfaceData = {
id: toolName,
name: toolName,
description: toolObject.description || "No description provided.",
parameters: (toolObject.parameters || []).map(param => {
let inputType = 'text';
const apiType = param.type ? param.type.toLowerCase() : 'string';
let valueType = 'string';
let label = param.description || param.name;
if (apiType === 'integer' || apiType === 'float') {
inputType = 'number';
valueType = 'number';
} else if (apiType === 'boolean') {
inputType = 'checkbox';
valueType = 'boolean';
} else if (apiType === 'array') {
inputType = 'textarea';
const itemType = param.items && param.items.type ? param.items.type.toLowerCase() : 'string';
valueType = `array<${itemType}>`;
label += ' (Array)';
}
return {
name: param.name,
type: inputType,
valueType: valueType,
label: label,
authServices: param.authSources,
required: param.required || false,
// defaultValue: param.default, can't do this yet bc tool manifest doesn't have default
};
})
};
console.debug("Transformed toolInterfaceData:", toolInterfaceData);
renderToolInterface(toolInterfaceData, toolDisplayArea);
} catch (error) {
if (error.name === 'AbortError') {
console.debug("Previous fetch was aborted, expected behavior.");
} else {
console.error(`Failed to load details for tool "${toolName}":`, error);
toolDisplayArea.innerHTML = `<p class="error">Failed to load details for ${toolName}. ${error.message}</p>`;
}
}
}

View File

@@ -0,0 +1,40 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Renders the main content area into the HTML.
* @param {string} containerId The ID of the DOM element to inject the content into.
* @param {string} idString The id of the item inside the main content area.
*/
function renderMainContent(containerId, idString) {
const mainContentContainer = document.getElementById(containerId);
if (!mainContentContainer) {
console.error(`Content container with ID "${containerId}" not found.`);
return;
}
const idAttribute = idString ? `id="${idString}"` : '';
const contentHTML = `
<div class="main-content-area">
<div class="top-bar">
</div>
<main class="content" ${idAttribute}">
<h1>Welcome to MCP Toolbox UI</h1>
<p>This is the main content area. Click a tab on the left to navigate.</p>
</main>
</div>
`;
mainContentContainer.innerHTML = contentHTML;
}

View File

@@ -0,0 +1,53 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Renders the navigation bar HTML content into the specified container element.
* @param {string} containerId The ID of the DOM element to inject the navbar into.
* @param {string | null} activePath The active tab from the navbar.
*/
function renderNavbar(containerId, activePath) {
const navbarContainer = document.getElementById(containerId);
if (!navbarContainer) {
console.error(`Navbar container with ID "${containerId}" not found.`);
return;
}
const navbarHTML = `
<nav class="left-nav">
<div class="nav-logo">
<img src="/ui/assets/mcptoolboxlogo.png" alt="App Logo">
</div>
<ul>
<li><a href="/ui/sources">Sources</a></li>
<!--<li><a href="/ui/authservices">Auth Services</a></li>-->
<li><a href="/ui/tools">Tools</a></li>
<li><a href="/ui/toolsets">Toolsets</a></li>
</ul>
</nav>
`;
navbarContainer.innerHTML = navbarHTML;
if (activePath) {
const navLinks = navbarContainer.querySelectorAll('.left-nav ul li a');
navLinks.forEach(link => {
const linkPath = new URL(link.href).pathname;
if (linkPath === activePath) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
}
}

View File

@@ -0,0 +1,162 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { isParamIncluded } from "./toolDisplay.js";
/**
* Runs a specific tool using the /api/tools/toolName/invoke endpoint
* @param {string} toolId The unique identifier for the tool.
* @param {!HTMLFormElement} form The form element containing parameter inputs.
* @param {!HTMLTextAreaElement} responseArea The textarea to display results or errors.
* @param {!Array<!Object>} parameters An array of parameter definition objects
* @param {!HTMLInputElement} prettifyCheckbox The checkbox to control JSON formatting.
* @param {function(?Object): void} updateLastResults Callback to store the last results.
*/
export async function handleRunTool(toolId, form, responseArea, parameters, prettifyCheckbox, updateLastResults, headers) {
const formData = new FormData(form);
const typedParams = {};
responseArea.value = 'Running tool...';
updateLastResults(null);
for (const param of parameters) {
const NAME = param.name;
const VALUE_TYPE = param.valueType;
const RAW_VALUE = formData.get(NAME);
const INCLUDE_CHECKED = isParamIncluded(toolId, NAME)
try {
if (!INCLUDE_CHECKED) {
console.debug(`Param ${NAME} was intentionally skipped.`)
// if param was purposely unchecked, don't include it in body
continue;
}
if (VALUE_TYPE === 'boolean') {
typedParams[NAME] = RAW_VALUE !== null;
console.debug(`Parameter ${NAME} (boolean) set to: ${typedParams[NAME]}`);
continue;
}
// process remaining types
if (VALUE_TYPE && VALUE_TYPE.startsWith('array<')) {
typedParams[NAME] = parseArrayParameter(RAW_VALUE, VALUE_TYPE, NAME);
} else {
switch (VALUE_TYPE) {
case 'number':
if (RAW_VALUE === "") {
console.debug(`Param ${NAME} was empty, setting to empty string.`)
typedParams[NAME] = "";
} else {
const num = Number(RAW_VALUE);
if (isNaN(num)) {
throw new Error(`Invalid number input for ${NAME}: ${RAW_VALUE}`);
}
typedParams[NAME] = num;
}
break;
case 'string':
default:
typedParams[NAME] = RAW_VALUE;
break;
}
}
} catch (error) {
console.error('Error processing parameter:', NAME, error);
responseArea.value = `Error for ${NAME}: ${error.message}`;
return;
}
}
console.debug('Running tool:', toolId, 'with typed params:', typedParams);
try {
const response = await fetch(`/api/tool/${toolId}/invoke`, {
method: 'POST',
headers: headers,
body: JSON.stringify(typedParams)
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP error ${response.status}: ${errorBody}`);
}
const results = await response.json();
updateLastResults(results);
displayResults(results, responseArea, prettifyCheckbox.checked);
} catch (error) {
console.error('Error running tool:', error);
responseArea.value = `Error: ${error.message}`;
updateLastResults(null);
}
}
/**
* Parses and validates a single array parameter from a raw string value.
* @param {string} rawValue The raw string value from FormData.
* @param {string} valueType The full array type string (e.g., "array<number>").
* @param {string} paramName The name of the parameter for error messaging.
* @return {!Array<*>} The parsed array.
* @throws {Error} If parsing or type validation fails.
*/
function parseArrayParameter(rawValue, valueType, paramName) {
const ELEMENT_TYPE = valueType.substring(6, valueType.length - 1);
let parsedArray;
try {
parsedArray = JSON.parse(rawValue);
} catch (e) {
throw new Error(`Invalid JSON format for ${paramName}. Expected an array. ${e.message}`);
}
if (!Array.isArray(parsedArray)) {
throw new Error(`Input for ${paramName} must be a JSON array (e.g., ["a", "b"]).`);
}
return parsedArray.map((item, index) => {
switch (ELEMENT_TYPE) {
case 'number':
const NUM = Number(item);
if (isNaN(NUM)) {
throw new Error(`Invalid number "${item}" found in array for ${paramName} at index ${index}.`);
}
return NUM;
case 'boolean':
return item === true || String(item).toLowerCase() === 'true';
case 'string':
default:
return item;
}
});
}
/**
* Displays the results from the tool run in the response area.
*/
export function displayResults(results, responseArea, prettify) {
if (results === null || results === undefined) {
return;
}
try {
const resultJson = JSON.parse(results.result);
if (prettify) {
responseArea.value = JSON.stringify(resultJson, null, 2);
} else {
responseArea.value = JSON.stringify(resultJson);
}
} catch (error) {
console.error("Error parsing or stringifying results:", error);
if (typeof results.result === 'string') {
responseArea.value = results.result;
} else {
responseArea.value = "Error displaying results. Invalid format.";
}
}
}

View File

@@ -0,0 +1,261 @@
// 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.
document.addEventListener('DOMContentLoaded', () => {
const sourceInfoArea = document.getElementById('source-info-area');
const secondaryPanelContent = document.getElementById('secondary-panel-content');
if (!secondaryPanelContent || !sourceInfoArea) {
console.error('Required DOM elements not found.');
return;
}
loadSources(secondaryPanelContent, sourceInfoArea)
});
const MOCK_SOURCE_MANIFEST = {
"sources": {
"my-alloydb-pg-source": {
kind: "alloydb-postgres",
project: "my-project-id",
region: "us-central1",
cluster: "my-cluster",
instance: "my-instance",
database: "my_db",
ipType: "public"
},
"my-bigtable-source": {
kind: "bigtable",
project: "my-project-id",
instance: "test-instance"
},
"my-couchbase-instance": {
kind: "couchbase",
connectionString: "couchebase://localhost:8091",
bucket: "travel-sample",
scope: "inventory"
}
}
};
/**
* Returns mock data for the source list.
* @returns {!Promise<Object>} Mocked API response data.
*/
async function getMockSourceListData() {
console.warn(`[MOCK] Returning mock data for all sources`);
return MOCK_SOURCE_MANIFEST;
}
/**
* Returns mock data for source details in the specified JSON format.
* @param {string} sourceName The name of the source.
* @returns {!Promise<Object>} Mocked API response data.
*/
async function getMockSourceDetailsData(sourceName) {
console.warn(`[MOCK] Returning mock data for source details: ${sourceName}`);
const SOURCE_DATA = MOCK_SOURCE_MANIFEST.sources[sourceName];
if (SOURCE_DATA) {
return {
"sources": {
[sourceName]: SOURCE_DATA
}
};
} else {
throw new Error(`Mock Source "${sourceName}" not found`);
}
}
/**
* Fetches a source list from the /api/sources endpoint and initiates creating the source list.
* @param {!HTMLElement} secondNavContent The HTML element where the source list will be rendered.
* @param {!HTMLElement} displayArea The HTML element where the details of a selected source will be displayed.
* @returns {!Promise<void>} A promise that resolves when the sources are loaded and rendered, or rejects on error.
*/
export async function loadSources(secondNavContent, displayArea) {
secondNavContent.innerHTML = '<p>Fetching sources...</p>';
try {
// MOCK VERSION OF API CALL - GET ALL SOURCES
const apiResponse = await getMockSourceListData();
// REAL API CALL - GET ALL SOURCES
// const response = await fetch(`/api/sources/`);
// if (!response.ok) {
// throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
// }
// const apiResponse = await response.json();
renderSourceList(apiResponse, secondNavContent, displayArea);
} catch (error) {
console.error('Failed to load sources:', error);
secondNavContent.innerHTML = `<p class="error">Failed to load sources: <pre><code>${error}</code></pre></p>`;
}
}
/**
* Renders the list of sources as buttons within the provided HTML element.
* @param {?{sources: ?Object<string,*>} } apiResponse The API response object containing the sources.
* @param {!HTMLElement} secondNavContent The HTML element to render the source list into.
* @param {!HTMLElement} displayArea The HTML element for displaying source details.
*/
function renderSourceList(apiResponse, secondNavContent, displayArea) {
secondNavContent.innerHTML = '';
console.log(apiResponse)
if (!apiResponse || typeof apiResponse.sources !== 'object' || apiResponse.sources === null) {
console.error('Error: Expected an object with a "sources" property, but received:', apiResponse);
secondNavContent.textContent = 'Error: Invalid response format from sources API.';
return;
}
const sourcesObject = apiResponse.sources;
const sourceNames = Object.keys(sourcesObject);
if (sourceNames.length === 0) {
secondNavContent.textContent = 'No sources found.';
return;
}
const ul = document.createElement('ul');
sourceNames.forEach(sourceName => {
const li = document.createElement('li');
const button = document.createElement('button');
button.textContent = sourceName;
button.dataset.sourcename = sourceName;
button.classList.add('second-nav-choice');
button.addEventListener('click', (event) => handleSourceClick(event, secondNavContent, displayArea));
li.appendChild(button);
ul.appendChild(li);
});
secondNavContent.appendChild(ul);
}
/**
* Handles the click event on a source button.
* @param {!Event} event The click event object.
* @param {!HTMLElement} secondNavContent The parent element containing the source buttons.
* @param {!HTMLElement} displayArea The HTML element where source details will be shown.
*/
function handleSourceClick(event, secondNavContent, displayArea) {
const sourceName = event.target.dataset.sourcename;
if (sourceName) {
const currentActive = secondNavContent.querySelector('.second-nav-choice.active');
if (currentActive) currentActive.classList.remove('active');
event.target.classList.add('active');
fetchSourceDetails(sourceName, displayArea);
}
}
/**
* Fetches and displays details for a specific source.
* @param {string} sourceName The name of the source.
* @param {!HTMLElement} displayArea The element to render details into.
*/
async function fetchSourceDetails(sourceName, displayArea) {
displayArea.innerHTML = `<p>Loading details for ${sourceName}...</p>`;
try {
// MOCK VERSION OF API CALL - GET SPECIFIC SOURCES BY NAME
const apiResponse = await getMockSourceDetailsData(sourceName);
// REAL API CALL - GET SPECIFIC SOURCES BY NAME
// const response = await fetch(`/api/source/${encodeURIComponent(sourceName)}`, { signal });
// if (!response.ok) {
// throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
// }
// const apiResponse = await response.json();
// Adjusted check for the new structure
if (!apiResponse.sources || !apiResponse.sources[sourceName]) {
throw new Error(`Source "${sourceName}" data not found in API response.`);
}
const sourceDetails = apiResponse.sources[sourceName];
displaySourceDetails(sourceName, sourceDetails, displayArea);
console.log("Source details:", sourceDetails);
} catch (error) {
if (error.name === 'AbortError') {
console.debug("Source detail fetch aborted.");
} else {
console.error(`Failed to load details for source "${sourceName}":`, error);
displayArea.innerHTML = `<p class="error">Failed to load details for ${sourceName}: ${error.message}</p>`;
}
}
}
/**
* Renders the details of a source manifest in a user-friendly format.
* @param {string} sourceName The name of the source.
* @param {!Object<string, *>} sourceDetails The object containing the source's attributes.
* @param {!HTMLElement} displayArea The HTML element to render the details into.
*/
function displaySourceDetails(sourceName, sourceDetails, displayArea) {
displayArea.innerHTML = '';
const title = document.createElement('h3');
const entries = Object.entries(sourceDetails);
title.textContent = `${sourceName}:`;
displayArea.appendChild(title);
if (entries.length === 0) {
const para = document.createElement('p');
para.textContent = 'No details available.';
displayArea.appendChild(para);
return;
}
// SINGLE LIST
// const ul = document.createElement('ul');
// ul.classList.add('source-details-list');
// for (const [key, value] of entries) {
// const li = document.createElement('li');
// const keyElement = document.createElement('strong');
// const valueElement = document.createElement('span');
// li.classList.add('source-detail-item');
// keyElement.textContent = `${key}: `;
// keyElement.classList.add('key')
// li.appendChild(keyElement);
// valueElement.textContent = String(value);
// valueElement.classList.add('value')
// li.appendChild(valueElement);
// ul.appendChild(li);
// }
// displayArea.appendChild(ul);
// DOUBLE LIST
const ul = document.createElement('ul');
ul.classList.add('source-details-list');
for (const [key, value] of entries) {
const li = document.createElement('li');
li.classList.add('source-detail-item');
const keyDiv = document.createElement('div');
keyDiv.classList.add('key');
keyDiv.textContent = key;
li.appendChild(keyDiv);
const valueDiv = document.createElement('div');
valueDiv.classList.add('value');
valueDiv.textContent = String(value);
li.appendChild(valueDiv);
ul.appendChild(li);
}
displayArea.appendChild(ul);
}

View File

@@ -0,0 +1,510 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { handleRunTool, displayResults } from './runTool.js';
/**
* Helper function to create form inputs for parameters.
*/
function createParamInput(param, toolId) {
const paramItem = document.createElement('div');
paramItem.className = 'param-item';
const label = document.createElement('label');
const INPUT_ID = `param-${toolId}-${param.name}`;
const NAME_TEXT = document.createTextNode(param.name);
label.setAttribute('for', INPUT_ID);
label.appendChild(NAME_TEXT);
const IS_AUTH_PARAM = param.authServices && param.authServices.length > 0;
let additionalLabelText = '';
if (IS_AUTH_PARAM) {
additionalLabelText += ' (auth)';
}
if (!param.required) {
additionalLabelText += ' (optional)';
}
if (additionalLabelText) {
const additionalSpan = document.createElement('span');
additionalSpan.textContent = additionalLabelText;
additionalSpan.classList.add('param-label-extras');
label.appendChild(additionalSpan);
}
paramItem.appendChild(label);
const inputCheckboxWrapper = document.createElement('div');
const inputContainer = document.createElement('div');
inputCheckboxWrapper.className = 'input-checkbox-wrapper';
inputContainer.className = 'param-input-element-container';
// Build parameter's value input box.
const PLACEHOLDER_LABEL = param.label;
let inputElement;
let boolValueLabel = null;
if (param.type === 'textarea') {
inputElement = document.createElement('textarea');
inputElement.rows = 3;
inputContainer.appendChild(inputElement);
} else if(param.type === 'checkbox') {
inputElement = document.createElement('input');
inputElement.type = 'checkbox';
inputElement.title = PLACEHOLDER_LABEL;
inputElement.checked = false;
// handle true/false label for boolean params
boolValueLabel = document.createElement('span');
boolValueLabel.className = 'checkbox-bool-label';
boolValueLabel.textContent = inputElement.checked ? ' true' : ' false';
inputContainer.appendChild(inputElement);
inputContainer.appendChild(boolValueLabel);
inputElement.addEventListener('change', () => {
boolValueLabel.textContent = inputElement.checked ? ' true' : ' false';
});
} else {
inputElement = document.createElement('input');
inputElement.type = param.type;
inputContainer.appendChild(inputElement);
}
inputElement.id = INPUT_ID;
inputElement.name = param.name;
inputElement.classList.add('param-input-element');
if (IS_AUTH_PARAM) {
inputElement.disabled = true;
inputElement.classList.add('auth-param-input');
if (param.type !== 'checkbox') {
inputElement.placeholder = param.authServices;
}
} else if (param.type !== 'checkbox') {
inputElement.placeholder = PLACEHOLDER_LABEL ? PLACEHOLDER_LABEL.trim() : '';
}
inputCheckboxWrapper.appendChild(inputContainer);
// create the "Include Param" checkbox
const INCLUDE_CHECKBOX_ID = `include-${INPUT_ID}`;
const includeContainer = document.createElement('div');
const includeCheckbox = document.createElement('input');
includeContainer.className = 'include-param-container';
includeCheckbox.type = 'checkbox';
includeCheckbox.id = INCLUDE_CHECKBOX_ID;
includeCheckbox.name = `include-${param.name}`;
includeCheckbox.title = 'Include this parameter'; // Add a tooltip
// default to checked, unless it's an optional parameter
includeCheckbox.checked = param.required;
includeContainer.appendChild(includeCheckbox);
inputCheckboxWrapper.appendChild(includeContainer);
paramItem.appendChild(inputCheckboxWrapper);
// function to update UI based on checkbox state
const updateParamIncludedState = () => {
const isIncluded = includeCheckbox.checked;
if (isIncluded) {
paramItem.classList.remove('disabled-param');
if (!IS_AUTH_PARAM) {
inputElement.disabled = false;
}
if (boolValueLabel) {
boolValueLabel.classList.remove('disabled');
}
} else {
paramItem.classList.add('disabled-param');
inputElement.disabled = true;
if (boolValueLabel) {
boolValueLabel.classList.add('disabled');
}
}
};
// add event listener to the include checkbox
includeCheckbox.addEventListener('change', updateParamIncludedState);
updateParamIncludedState();
return paramItem;
}
/**
* Function to create the header editor popup modal.
* @param {string} toolId The unique identifier for the tool.
* @param {!Object<string, string>} currentHeaders The current headers.
* @param {function(!Object<string, string>): void} saveCallback A function to be
* called when the "Save" button is clicked and the headers are successfully
* parsed. The function receives the updated headers object as its argument.
* @return {!HTMLDivElement} The outermost div element of the created modal.
*/
function createHeaderEditorModal(toolId, currentHeaders, saveCallback) {
const MODAL_ID = `header-modal-${toolId}`;
let modal = document.getElementById(MODAL_ID);
if (modal) {
modal.remove();
}
modal = document.createElement('div');
modal.id = MODAL_ID;
modal.className = 'header-modal';
const modalContent = document.createElement('div');
const modalHeader = document.createElement('h5');
const headersTextarea = document.createElement('textarea');
modalContent.className = 'header-modal-content';
modalHeader.textContent = 'Edit Request Headers';
headersTextarea.id = `headers-textarea-${toolId}`;
headersTextarea.className = 'headers-textarea';
headersTextarea.rows = 10;
headersTextarea.value = JSON.stringify(currentHeaders, null, 2);
modalContent.appendChild(modalHeader);
modalContent.appendChild(headersTextarea);
const modalActions = document.createElement('div');
const closeButton = document.createElement('button');
const saveButton = document.createElement('button');
const authTokenDropdown = createAuthTokenInfoDropdown();
modalActions.className = 'header-modal-actions';
closeButton.textContent = 'Close';
closeButton.className = 'btn btn--closeHeaders';
closeButton.addEventListener('click', () => closeHeaderEditor(toolId));
saveButton.textContent = 'Save';
saveButton.className = 'btn btn--saveHeaders';
saveButton.addEventListener('click', () => {
try {
const updatedHeaders = JSON.parse(headersTextarea.value);
saveCallback(updatedHeaders);
closeHeaderEditor(toolId);
} catch (e) {
alert('Invalid JSON format for headers.');
console.error("Header JSON parse error:", e);
}
});
modalActions.appendChild(closeButton);
modalActions.appendChild(saveButton);
modalContent.appendChild(modalActions);
modalContent.appendChild(authTokenDropdown);
modal.appendChild(modalContent);
// Close modal if clicked outside
window.addEventListener('click', (event) => {
if (event.target === modal) {
closeHeaderEditor(toolId);
}
});
return modal;
}
/**
* Function to open the header popup.
*/
function openHeaderEditor(toolId) {
const modal = document.getElementById(`header-modal-${toolId}`);
if (modal) {
modal.style.display = 'block';
}
}
/**
* Function to close the header popup.
*/
function closeHeaderEditor(toolId) {
const modal = document.getElementById(`header-modal-${toolId}`);
if (modal) {
modal.style.display = 'none';
}
}
/**
* Creates a dropdown element showing information on how to extract Google auth tokens.
* @return {HTMLDetailsElement} The details element representing the dropdown.
*/
function createAuthTokenInfoDropdown() {
const details = document.createElement('details');
const summary = document.createElement('summary');
const content = document.createElement('div');
details.className = 'auth-token-details';
details.appendChild(summary);
summary.textContent = 'How to extract Google OAuth ID Token';
content.className = 'auth-token-content';
// auth instruction dropdown
const tabButtons = document.createElement('div');
const leftTab = document.createElement('button');
const rightTab = document.createElement('button');
tabButtons.className = 'auth-tab-group';
leftTab.className = 'auth-tab-picker active';
leftTab.textContent = 'With Standard Account';
leftTab.setAttribute('data-tab', 'standard');
rightTab.className = 'auth-tab-picker';
rightTab.textContent = 'With Service Account';
rightTab.setAttribute('data-tab', 'service');
tabButtons.appendChild(leftTab);
tabButtons.appendChild(rightTab);
content.appendChild(tabButtons);
const tabContentContainer = document.createElement('div');
const standardAccInstructions = document.createElement('div');
const serviceAccInstructions = document.createElement('div');
standardAccInstructions.id = 'auth-tab-standard';
standardAccInstructions.className = 'auth-tab-content active';
standardAccInstructions.innerHTML = AUTH_TOKEN_INSTRUCTIONS_STANDARD;
serviceAccInstructions.id = 'auth-tab-service';
serviceAccInstructions.className = 'auth-tab-content';
serviceAccInstructions.innerHTML = AUTH_TOKEN_INSTRUCTIONS_SERVICE_ACCOUNT;
tabContentContainer.appendChild(standardAccInstructions);
tabContentContainer.appendChild(serviceAccInstructions);
content.appendChild(tabContentContainer);
// switching tabs logic
const tabBtns = [leftTab, rightTab];
const tabContents = [standardAccInstructions, serviceAccInstructions];
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
// deactivate all buttons and contents
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
const tabId = btn.getAttribute('data-tab');
const activeContent = content.querySelector(`#auth-tab-${tabId}`);
if (activeContent) {
activeContent.classList.add('active');
}
});
});
details.appendChild(content);
return details;
}
/**
* Renders the tool display area.
*/
export function renderToolInterface(tool, containerElement) {
const TOOL_ID = tool.id;
containerElement.innerHTML = '';
let lastResults = null;
let currentHeaders = {
"Content-Type": "application/json"
};
// function to update lastResults so we can toggle json
const updateLastResults = (newResults) => {
lastResults = newResults;
};
const updateCurrentHeaders = (newHeaders) => {
currentHeaders = newHeaders;
const newModal = createHeaderEditorModal(TOOL_ID, currentHeaders, updateCurrentHeaders);
containerElement.appendChild(newModal);
};
const gridContainer = document.createElement('div');
gridContainer.className = 'tool-details-grid';
const toolInfoContainer = document.createElement('div');
const nameBox = document.createElement('div');
const descBox = document.createElement('div');
nameBox.className = 'tool-box tool-name';
nameBox.innerHTML = `<h5>Name:</h5><p>${tool.name}</p>`;
descBox.className = 'tool-box tool-description';
descBox.innerHTML = `<h5>Description:</h5><p>${tool.description}</p>`;
toolInfoContainer.className = 'tool-info';
toolInfoContainer.appendChild(nameBox);
toolInfoContainer.appendChild(descBox);
gridContainer.appendChild(toolInfoContainer);
const DISLCAIMER_INFO = "*Checked parameters are sent with the value from their text field. Empty fields will be sent as an empty string. To exclude a parameter, uncheck it."
const paramsContainer = document.createElement('div');
const form = document.createElement('form');
const paramsHeader = document.createElement('div');
const disclaimerText = document.createElement('div');
paramsContainer.className = 'tool-params tool-box';
paramsContainer.innerHTML = '<h5>Parameters:</h5>';
paramsHeader.className = 'params-header';
paramsContainer.appendChild(paramsHeader);
disclaimerText.textContent = DISLCAIMER_INFO;
disclaimerText.className = 'params-disclaimer';
paramsContainer.appendChild(disclaimerText);
form.id = `tool-params-form-${TOOL_ID}`;
tool.parameters.forEach(param => {
form.appendChild(createParamInput(param, TOOL_ID));
});
paramsContainer.appendChild(form);
gridContainer.appendChild(paramsContainer);
containerElement.appendChild(gridContainer);
const RESPONSE_AREA_ID = `tool-response-area-${TOOL_ID}`;
const runButtonContainer = document.createElement('div');
const editHeadersButton = document.createElement('button');
const runButton = document.createElement('button');
editHeadersButton.className = 'btn btn--editHeaders';
editHeadersButton.textContent = 'Edit Headers';
editHeadersButton.addEventListener('click', () => openHeaderEditor(TOOL_ID));
runButtonContainer.className = 'run-button-container';
runButtonContainer.appendChild(editHeadersButton);
runButton.className = 'btn btn--run';
runButton.textContent = 'Run Tool';
runButtonContainer.appendChild(runButton);
containerElement.appendChild(runButtonContainer);
// response Area (bottom)
const responseContainer = document.createElement('div');
const responseHeaderControls = document.createElement('div');
const responseHeader = document.createElement('h5');
const responseArea = document.createElement('textarea');
responseContainer.className = 'tool-response tool-box';
responseHeaderControls.className = 'response-header-controls';
responseHeader.textContent = 'Response:';
responseHeaderControls.appendChild(responseHeader);
// prettify box
const PRETTIFY_ID = `prettify-${TOOL_ID}`;
const prettifyDiv = document.createElement('div');
const prettifyLabel = document.createElement('label');
const prettifyCheckbox = document.createElement('input');
prettifyDiv.className = 'prettify-container';
prettifyLabel.setAttribute('for', PRETTIFY_ID);
prettifyLabel.textContent = 'Prettify JSON';
prettifyLabel.className = 'prettify-label';
prettifyCheckbox.type = 'checkbox';
prettifyCheckbox.id = PRETTIFY_ID;
prettifyCheckbox.checked = true;
prettifyCheckbox.className = 'prettify-checkbox';
prettifyDiv.appendChild(prettifyLabel);
prettifyDiv.appendChild(prettifyCheckbox);
responseHeaderControls.appendChild(prettifyDiv);
responseContainer.appendChild(responseHeaderControls);
responseArea.id = RESPONSE_AREA_ID;
responseArea.readOnly = true;
responseArea.placeholder = 'Results will appear here...';
responseArea.className = 'tool-response-area';
responseArea.rows = 10;
responseContainer.appendChild(responseArea);
containerElement.appendChild(responseContainer);
// create and append the header editor modal
const headerModal = createHeaderEditorModal(TOOL_ID, currentHeaders, updateCurrentHeaders);
containerElement.appendChild(headerModal);
prettifyCheckbox.addEventListener('change', () => {
if (lastResults) {
displayResults(lastResults, responseArea, prettifyCheckbox.checked);
}
});
runButton.addEventListener('click', (event) => {
event.preventDefault();
handleRunTool(TOOL_ID, form, responseArea, tool.parameters, prettifyCheckbox, updateLastResults, currentHeaders);
});
}
/**
* Checks if a specific parameter is marked as included for a given tool.
* @param {string} toolId The ID of the tool.
* @param {string} paramName The name of the parameter.
* @return {boolean|null} True if the parameter's include checkbox is checked,
* False if unchecked, Null if the checkbox element is not found.
*/
export function isParamIncluded(toolId, paramName) {
const inputId = `param-${toolId}-${paramName}`;
const includeCheckboxId = `include-${inputId}`;
const includeCheckbox = document.getElementById(includeCheckboxId);
if (includeCheckbox && includeCheckbox.type === 'checkbox') {
return includeCheckbox.checked;
}
console.warn(`Include checkbox not found for ID: ${includeCheckboxId}`);
return null;
}
// Templates for inserting token retrieval instructions into edit header modal
const AUTH_TOKEN_INSTRUCTIONS_SERVICE_ACCOUNT = `
<p>To obtain a Google OAuth ID token using a service account:</p>
<ol>
<li>Make sure you are on the intended SERVICE account (typically contain iam.gserviceaccount.com). Verify by running the command below.
<pre><code>gcloud auth list</code></pre>
</li>
<li>Print an id token with the audience set to your clientID defined in tools file:
<pre><code>gcloud auth print-identity-token --audiences=YOUR_CLIENT_ID_HERE</code></pre>
</li>
<li>Copy the output token.</li>
<li>Paste this token into the header in JSON editor. The key should be the name of your auth service followed by <code>_token</code>
<pre><code>{
"Content-Type": "application/json",
"my-google-auth_token": "YOUR_ID_TOKEN_HERE"
} </code></pre>
</li>
</ol>
<p>This token is typically short-lived.</p>`;
const AUTH_TOKEN_INSTRUCTIONS_STANDARD = `
<p>To obtain a Google OAuth ID token using a standard account:</p>
<ol>
<li>Make sure you are on your intended standard account. Verify by running the command below.
<pre><code>gcloud auth list</code></pre>
</li>
<li>Within your Cloud Console, add the following link to the "Authorized Redirect URIs".</li>
<pre><code>https://developers.google.com/oauthplayground</code></pre>
<li>Go to the Google OAuth Playground site: <a href="https://developers.google.com/oauthplayground/" target="_blank">https://developers.google.com/oauthplayground/</a></li>
<li>In the top right settings menu, select "Use your own OAuth Credentials".</li>
<li>Input your clientID (from tools file), along with the client secret from Cloud Console.</li>
<li>Inside the Google OAuth Playground, select "Google OAuth2 API v2.</li>
<ul>
<li>Select "Authorize APIs".</li>
<li>Select "Exchange Authorization codes for tokens"</li>
<li>Copy the id_token field provided in the response.</li>
</ul>
<li>Paste this token into the header in JSON editor. The key should be the name of your auth service followed by <code>_token</code>
<pre><code>{
"Content-Type": "application/json",
"my-google-auth_token": "YOUR_ID_TOKEN_HERE"
} </code></pre>
</li>
</ol>
<p>This token is typically short-lived.</p>`;

View File

@@ -0,0 +1,32 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { loadTools } from "./loadTools.js";
/**
* These functions runs after the browser finishes loading and parsing HTML structure.
* This ensures that elements can be safely accessed.
*/
document.addEventListener('DOMContentLoaded', () => {
const toolDisplayArea = document.getElementById('tool-display-area');
const secondaryPanelContent = document.getElementById('secondary-panel-content');
const DEFAULT_TOOLSET = ""; // will return all toolsets
if (!secondaryPanelContent || !toolDisplayArea) {
console.error('Required DOM elements not found.');
return;
}
loadTools(secondaryPanelContent, toolDisplayArea, DEFAULT_TOOLSET);
});

View File

@@ -0,0 +1,51 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { loadTools } from "./loadTools.js";
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('toolset-search-input');
const searchButton = document.getElementById('toolset-search-button');
const secondNavContent = document.getElementById('secondary-panel-content');
const toolDisplayArea = document.getElementById('tool-display-area');
if (!searchInput || !searchButton || !secondNavContent || !toolDisplayArea) {
console.error('Required DOM elements not found.');
return;
}
// Event listener for search button click
searchButton.addEventListener('click', () => {
toolDisplayArea.innerHTML = '';
const toolsetName = searchInput.value.trim();
if (toolsetName) {
loadTools(secondNavContent, toolDisplayArea, toolsetName)
} else {
secondNavContent.innerHTML = '<p>Please enter a toolset name to see available tools. <br><br>To view the default toolset that consists of all tools, please select the "Tools" tab.</p>';
}
});
// Event listener for Enter key in search input
searchInput.addEventListener('keypress', (event) => {
toolDisplayArea.innerHTML = '';
if (event.key === 'Enter') {
const toolsetName = searchInput.value.trim();
if (toolsetName) {
loadTools(secondNavContent, toolDisplayArea, toolsetName);
} else {
secondNavContent.innerHTML = '<p>Please enter a toolset name to see available tools. <br><br>To view the default toolset that consists of all tools, please select the "Tools" tab.</p>';
}
}
});
})

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sources View</title>
<link rel="stylesheet" href="/ui/css/style.css">
</head>
<body>
<div id="navbar-container" data-active-nav="/ui/sources"></div>
<aside class="second-nav">
<h4>My Sources</h4>
<div id="secondary-panel-content">
<p>Fetching sources...</p>
</div>
</aside>
<div id="main-content-container"></div>
<script type="module" src="/ui/js/sources.js"></script>
<script src="/ui/js/navbar.js"></script>
<script src="/ui/js/mainContent.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const navbarContainer = document.getElementById('navbar-container');
const activeNav = navbarContainer.getAttribute('data-active-nav');
renderNavbar('navbar-container', activeNav);
renderMainContent('main-content-container', 'source-info-area')
});
</script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tools View</title>
<link rel="stylesheet" href="/ui/css/style.css">
</head>
<body>
<div id="navbar-container" data-active-nav="/ui/tools"></div>
<aside class="second-nav">
<h4>My Tools</h4>
<div id="secondary-panel-content">
<p>Fetching tools...</p>
</div>
</aside>
<div id="main-content-container"></div>
<script type="module" src="/ui/js/tools.js"></script>
<script src="/ui/js/navbar.js"></script>
<script src="/ui/js/mainContent.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const navbarContainer = document.getElementById('navbar-container');
const activeNav = navbarContainer.getAttribute('data-active-nav');
renderNavbar('navbar-container', activeNav);
renderMainContent('main-content-container', 'tool-display-area')
});
</script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toolsets View</title>
<link rel="stylesheet" href="/ui/css/style.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<div id="navbar-container" data-active-nav="/ui/toolsets"></div>
<aside class="second-nav">
<h4>Retrieve Toolset</h4>
<div class="search-container">
<input type="text" id="toolset-search-input" placeholder="Enter toolset name...">
<button id="toolset-search-button" aria-label="Retrieve Tools">
<span class="material-icons">search</span>
</button>
</div>
<div id="secondary-panel-content">
<p>Retrieve toolset to see available tools.</p>
</div>
</aside>
<div id="main-content-container"></div>
<script type="module" src="/ui/js/toolsets.js"></script>
<script src="/ui/js/navbar.js"></script>
<script src="/ui/js/mainContent.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const navbarContainer = document.getElementById('navbar-container');
const activeNav = navbarContainer.getAttribute('data-active-nav');
renderNavbar('navbar-container', activeNav);
renderMainContent('main-content-container', 'tool-display-area');
});
</script>
</body>
</html>

55
internal/server/web.go Normal file
View File

@@ -0,0 +1,55 @@
package server
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
//go:embed all:static
var staticContent embed.FS
// webRouter creates a router that represents the routes under /ui
func webRouter() (chi.Router, error) {
r := chi.NewRouter()
r.Use(middleware.StripSlashes)
// direct routes for html pages to provide clean URLs
r.Get("/", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/index.html") })
r.Get("/sources", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/sources.html") })
r.Get("/tools", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/tools.html") })
r.Get("/toolsets", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/toolsets.html") })
// handler for all other static files/assets
staticFS, _ := fs.Sub(staticContent, "static")
r.Handle("/*", http.StripPrefix("/ui", http.FileServer(http.FS(staticFS))))
return r, nil
}
func serveHTML(w http.ResponseWriter, r *http.Request, filepath string) {
file, err := staticContent.Open(filepath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
defer file.Close()
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
return
}
fileInfo, err := file.Stat()
if err != nil {
return
}
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), bytes.NewReader(fileBytes))
}

193
internal/server/web_test.go Normal file
View File

@@ -0,0 +1,193 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-goquery/goquery"
)
// TestWebEndpoint tests the routes defined in webRouter mounted under /ui.
func TestWebEndpoint(t *testing.T) {
mainRouter := chi.NewRouter()
webR, err := webRouter()
if err != nil {
t.Fatalf("Failed to create webRouter: %v", err)
}
mainRouter.Mount("/ui", webR)
ts := httptest.NewServer(mainRouter)
defer ts.Close()
testCases := []struct {
name string
path string
wantStatus int
wantContentType string
wantPageTitle string
}{
{
name: "web index page",
path: "/ui",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Toolbox UI",
},
{
name: "web index page with trailing slash",
path: "/ui/",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Toolbox UI",
},
{
name: "web tools page",
path: "/ui/tools",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Tools View",
},
{
name: "web tools page with trailing slash",
path: "/ui/tools/",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Tools View",
},
{
name: "web toolsets page",
path: "/ui/toolsets",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Toolsets View",
},
{
name: "web toolsets page with trailing slash",
path: "/ui/toolsets/",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Toolsets View",
},
{
name: "web sources page",
path: "/ui/sources",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Sources View",
},
{
name: "web sources page with trailing slash",
path: "/ui/sources/",
wantStatus: http.StatusOK,
wantContentType: "text/html",
wantPageTitle: "Sources View",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reqURL := ts.URL + tc.path
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := ts.Client()
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("Unexpected status code for %s: got %d, want %d, body: %s", tc.path, resp.StatusCode, tc.wantStatus, string(body))
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, tc.wantContentType) {
t.Errorf("Unexpected Content-Type header for %s: got %s, want prefix %s", tc.path, contentType, tc.wantContentType)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
if err != nil {
t.Fatalf("Failed to parse HTML: %v", err)
}
gotPageTitle := doc.Find("title").Text()
if gotPageTitle != tc.wantPageTitle {
t.Errorf("Unexpected page title for %s: got %q, want %q", tc.path, gotPageTitle, tc.wantPageTitle)
}
pageURL := resp.Request.URL
verifyLinkedResources(t, ts, pageURL, doc)
})
}
}
// verifyLinkedResources checks that resources linked in the HTML are served correctly.
func verifyLinkedResources(t *testing.T, ts *httptest.Server, pageURL *url.URL, doc *goquery.Document) {
t.Helper()
selectors := map[string]string{
"stylesheet": "link[rel=stylesheet]",
"script": "script[src]",
}
attrMap := map[string]string{
"stylesheet": "href",
"script": "src",
}
foundResource := false
for resourceType, selector := range selectors {
doc.Find(selector).Each(func(i int, s *goquery.Selection) {
foundResource = true
attrName := attrMap[resourceType]
resourcePath, exists := s.Attr(attrName)
if !exists || resourcePath == "" {
t.Errorf("Resource element %s is missing attribute %s on page %s", selector, attrName, pageURL.String())
return
}
// Resolve the URL relative to the page URL
resURL, err := url.Parse(resourcePath)
if err != nil {
t.Errorf("Failed to parse resource path %q on page %s: %v", resourcePath, pageURL.String(), err)
return
}
absoluteResourceURL := pageURL.ResolveReference(resURL)
// Skip external hosts
if absoluteResourceURL.Host != pageURL.Host {
t.Logf("Skipping resource on different host: %s", absoluteResourceURL.String())
return
}
resp, err := ts.Client().Get(absoluteResourceURL.String())
if err != nil {
t.Errorf("Failed to GET %s resource %s: %v", resourceType, absoluteResourceURL.String(), err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Resource %s %s: expected status OK (200), but got %d", resourceType, absoluteResourceURL.String(), resp.StatusCode)
}
})
}
if !foundResource {
t.Logf("No stylesheet or script resources found to check on page %s", pageURL.String())
}
}

View File

@@ -99,7 +99,7 @@ func initDuckDbConnection(ctx context.Context, tracer trace.Tracer, name string,
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name)
defer span.End()
var configStr string = getDuckDbConfiguration(dbFilePath, duckdbConfiguration)
var configStr = getDuckDbConfiguration(dbFilePath, duckdbConfiguration)
//Open database connection
db, err := sql.Open("duckdb", configStr)

View File

@@ -0,0 +1,128 @@
// 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 tidb
import (
"context"
"database/sql"
"fmt"
"regexp"
_ "github.com/go-sql-driver/mysql"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"go.opentelemetry.io/otel/trace"
)
const SourceKind string = "tidb"
const TiDBCloudHostPattern string = `gateway\d{2}\.(.+)\.(prod|dev|staging)\.(.+)\.tidbcloud\.com`
// 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
}
// If the host is a TiDB Cloud instance, force to use SSL
if IsTiDBCloudHost(actual.Host) {
actual.UseSSL = true
}
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"`
UseSSL bool `yaml:"ssl"`
}
func (r Config) SourceConfigKind() string {
return SourceKind
}
func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
pool, err := initTiDBConnectionPool(ctx, tracer, r.Name, r.Host, r.Port, r.User, r.Password, r.Database, r.UseSSL)
if err != nil {
return nil, fmt.Errorf("unable to create pool: %w", err)
}
err = pool.PingContext(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 *sql.DB
}
func (s *Source) SourceKind() string {
return SourceKind
}
func (s *Source) TiDBPool() *sql.DB {
return s.Pool
}
func IsTiDBCloudHost(host string) bool {
pattern := `gateway\d{2}\.(.+)\.(prod|dev|staging)\.(.+)\.tidbcloud\.com`
match, err := regexp.MatchString(pattern, host)
if err != nil {
return false
}
return match
}
func initTiDBConnectionPool(ctx context.Context, tracer trace.Tracer, name, host, port, user, pass, dbname string, useSSL bool) (*sql.DB, error) {
//nolint:all // Reassigned ctx
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceKind, name)
defer span.End()
// Configure the driver to connect to the database
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&tls=%t", user, pass, host, port, dbname, useSSL)
// Interact with the driver directly as you normally would
pool, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("sql.Open: %w", err)
}
return pool, nil
}

View File

@@ -0,0 +1,258 @@
// 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 tidb_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/sources/tidb"
"github.com/googleapis/genai-toolbox/internal/testutils"
)
func TestParseFromYamlTiDB(t *testing.T) {
tcs := []struct {
desc string
in string
want server.SourceConfigs
}{
{
desc: "basic example",
in: `
sources:
my-tidb-instance:
kind: tidb
host: 0.0.0.0
port: my-port
database: my_db
user: my_user
password: my_pass
`,
want: server.SourceConfigs{
"my-tidb-instance": tidb.Config{
Name: "my-tidb-instance",
Kind: tidb.SourceKind,
Host: "0.0.0.0",
Port: "my-port",
Database: "my_db",
User: "my_user",
Password: "my_pass",
UseSSL: false,
},
},
},
{
desc: "with SSL enabled",
in: `
sources:
my-tidb-cloud:
kind: tidb
host: gateway01.us-west-2.prod.aws.tidbcloud.com
port: 4000
database: test_db
user: cloud_user
password: cloud_pass
ssl: true
`,
want: server.SourceConfigs{
"my-tidb-cloud": tidb.Config{
Name: "my-tidb-cloud",
Kind: tidb.SourceKind,
Host: "gateway01.us-west-2.prod.aws.tidbcloud.com",
Port: "4000",
Database: "test_db",
User: "cloud_user",
Password: "cloud_pass",
UseSSL: true,
},
},
},
{
desc: "Change SSL enabled due to TiDB Cloud host",
in: `
sources:
my-tidb-cloud:
kind: tidb
host: gateway01.us-west-2.prod.aws.tidbcloud.com
port: 4000
database: test_db
user: cloud_user
password: cloud_pass
`,
want: server.SourceConfigs{
"my-tidb-cloud": tidb.Config{
Name: "my-tidb-cloud",
Kind: tidb.SourceKind,
Host: "gateway01.us-west-2.prod.aws.tidbcloud.com",
Port: "4000",
Database: "test_db",
User: "cloud_user",
Password: "cloud_pass",
UseSSL: true,
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Sources server.SourceConfigs `yaml:"sources"`
}{}
// Parse contents
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 %v, got %v", tc.want, got.Sources)
}
})
}
}
func TestFailParseFromYaml(t *testing.T) {
tcs := []struct {
desc string
in string
err string
}{
{
desc: "extra field",
in: `
sources:
my-tidb-instance:
kind: tidb
host: 0.0.0.0
port: my-port
database: my_db
user: my_user
password: my_pass
ssl: false
foo: bar
`,
err: "unable to parse source \"my-tidb-instance\" as \"tidb\": [2:1] unknown field \"foo\"\n 1 | database: my_db\n> 2 | foo: bar\n ^\n 3 | host: 0.0.0.0\n 4 | kind: tidb\n 5 | password: my_pass\n 6 | ",
},
{
desc: "missing required field",
in: `
sources:
my-tidb-instance:
kind: tidb
port: my-port
database: my_db
user: my_user
password: my_pass
ssl: false
`,
err: "unable to parse source \"my-tidb-instance\" as \"tidb\": 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"`
}{}
// Parse contents
err := yaml.Unmarshal(testutils.FormatYaml(tc.in), &got)
if err == nil {
t.Fatalf("expect parsing to fail")
}
errStr := err.Error()
if errStr != tc.err {
t.Fatalf("unexpected error: got %q, want %q", errStr, tc.err)
}
})
}
}
func TestIsTiDBCloudHost(t *testing.T) {
tcs := []struct {
desc string
host string
want bool
}{
{
desc: "valid TiDB Cloud host - ap-southeast-1",
host: "gateway01.ap-southeast-1.prod.aws.tidbcloud.com",
want: true,
},
{
desc: "invalid TiDB Cloud host - wrong domain",
host: "gateway01.ap-southeast-1.prod.aws.tdbcloud.com",
want: false,
},
{
desc: "local IP address",
host: "127.0.0.1",
want: false,
},
{
desc: "valid TiDB Cloud host - us-west-2",
host: "gateway01.us-west-2.prod.aws.tidbcloud.com",
want: true,
},
{
desc: "valid TiDB Cloud host - dev environment",
host: "gateway02.eu-west-1.dev.aws.tidbcloud.com",
want: true,
},
{
desc: "valid TiDB Cloud host - staging environment",
host: "gateway03.us-east-1.staging.aws.tidbcloud.com",
want: true,
},
{
desc: "invalid - wrong gateway format",
host: "gateway1.us-west-2.prod.aws.tidbcloud.com",
want: false,
},
{
desc: "invalid - missing environment",
host: "gateway01.us-west-2.aws.tidbcloud.com",
want: false,
},
{
desc: "invalid - wrong subdomain",
host: "gateway01.us-west-2.prod.aws.tidbcloud.org",
want: false,
},
{
desc: "invalid - localhost",
host: "localhost",
want: false,
},
{
desc: "invalid - private IP",
host: "192.168.1.1",
want: false,
},
{
desc: "invalid - empty string",
host: "",
want: false,
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := tidb.IsTiDBCloudHost(tc.host)
if got != tc.want {
t.Fatalf("isTiDBCloudHost(%q) = %v, want %v", tc.host, got, tc.want)
}
})
}
}

View File

@@ -135,25 +135,6 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
query := t.Client.Query(sql)
query.Location = t.Client.Location
// This block handles Data Manipulation Language (DML) and Data Definition Language (DDL) statements.
// These statements (e.g., INSERT, UPDATE, CREATE TABLE) do not return a row set.
// Instead, we execute them as a job, wait for completion, and return a success
// message, including the number of affected rows for DML operations.
if statementType != "SELECT" {
job, err := query.Run(ctx)
if err != nil {
return nil, fmt.Errorf("failed to start DML/DDL job: %w", err)
}
status, err := job.Wait(ctx)
if err != nil {
return nil, fmt.Errorf("failed to wait for DML/DDL job to complete: %w", err)
}
if err := status.Err(); err != nil {
return nil, fmt.Errorf("DML/DDL job failed with error: %w", err)
}
return "Operation completed successfully.", nil
}
// This block handles SELECT statements, which return a row set.
// We iterate through the results, convert each row into a map of
// column names to values, and return the collection of rows.
@@ -177,10 +158,21 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
}
out = append(out, vMap)
}
if out == nil {
// If the query returned any rows, return them directly.
if len(out) > 0 {
return out, nil
}
// This handles the standard case for a SELECT query that successfully
// executes but returns zero rows.
if statementType == "SELECT" {
return "The query returned 0 rows.", nil
}
return out, nil
// This is the fallback for a successful query that doesn't return content.
// In most cases, this will be for DML/DDL statements like INSERT, UPDATE, CREATE, etc.
// However, it is also possible that this was a query that was expected to return rows
// but returned none, a case that we cannot distinguish here.
return "Query executed successfully and returned no content.", nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {

View File

@@ -17,6 +17,7 @@ package bigquerysql
import (
"context"
"fmt"
"reflect"
"strings"
bigqueryapi "cloud.google.com/go/bigquery"
@@ -24,6 +25,7 @@ import (
"github.com/googleapis/genai-toolbox/internal/sources"
bigqueryds "github.com/googleapis/genai-toolbox/internal/sources/bigquery"
"github.com/googleapis/genai-toolbox/internal/tools"
bigqueryrestapi "google.golang.org/api/bigquery/v2"
"google.golang.org/api/iterator"
)
@@ -45,6 +47,7 @@ func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.T
type compatibleSource interface {
BigQueryClient() *bigqueryapi.Client
BigQueryRestService() *bigqueryrestapi.Service
}
// validate compatible sources are still compatible
@@ -101,6 +104,7 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
Statement: cfg.Statement,
AuthRequired: cfg.AuthRequired,
Client: s.BigQueryClient(),
RestService: s.BigQueryRestService(),
manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}
@@ -117,15 +121,17 @@ type Tool struct {
Parameters tools.Parameters `yaml:"parameters"`
TemplateParameters tools.Parameters `yaml:"templateParameters"`
AllParams tools.Parameters `yaml:"allParams"`
Client *bigqueryapi.Client
Statement string
manifest tools.Manifest
mcpManifest tools.McpManifest
Statement string
Client *bigqueryapi.Client
RestService *bigqueryrestapi.Service
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
namedArgs := make([]bigqueryapi.QueryParameter, 0, len(params))
highLevelParams := make([]bigqueryapi.QueryParameter, 0, len(t.Parameters))
lowLevelParams := make([]*bigqueryrestapi.QueryParameter, 0, len(t.Parameters))
paramsMap := params.AsMap()
newStatement, err := tools.ResolveTemplateParams(t.TemplateParameters, t.Statement, paramsMap)
if err != nil {
@@ -136,14 +142,11 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
name := p.GetName()
value := paramsMap[name]
// BigQuery's QueryParameter only accepts typed slices as input
// This checks if the param is an array.
// If yes, convert []any to typed slice (e.g []string, []int)
switch arrayParam := p.(type) {
case *tools.ArrayParameter:
// This block for converting []any to typed slices is still necessary and correct.
if arrayParam, ok := p.(*tools.ArrayParameter); ok {
arrayParamValue, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("unable to convert parameter `%s` to []any %w", name, err)
return nil, fmt.Errorf("unable to convert parameter `%s` to []any", name)
}
itemType := arrayParam.GetItems().GetType()
var err error
@@ -153,22 +156,69 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
}
}
if strings.Contains(t.Statement, "@"+name) {
namedArgs = append(namedArgs, bigqueryapi.QueryParameter{
Name: name,
Value: value,
})
} else {
namedArgs = append(namedArgs, bigqueryapi.QueryParameter{
Value: value,
})
// Determine if the parameter is named or positional for the high-level client.
var paramNameForHighLevel string
if strings.Contains(newStatement, "@"+name) {
paramNameForHighLevel = name
}
// 1. Create the high-level parameter for the final query execution.
highLevelParams = append(highLevelParams, bigqueryapi.QueryParameter{
Name: paramNameForHighLevel,
Value: value,
})
// 2. Create the low-level parameter for the dry run, using the defined type from `p`.
lowLevelParam := &bigqueryrestapi.QueryParameter{
Name: paramNameForHighLevel,
ParameterType: &bigqueryrestapi.QueryParameterType{},
ParameterValue: &bigqueryrestapi.QueryParameterValue{},
}
if arrayParam, ok := p.(*tools.ArrayParameter); ok {
// Handle array types based on their defined item type.
lowLevelParam.ParameterType.Type = "ARRAY"
itemType, err := BQTypeStringFromToolType(arrayParam.GetItems().GetType())
if err != nil {
return nil, err
}
lowLevelParam.ParameterType.ArrayType = &bigqueryrestapi.QueryParameterType{Type: itemType}
// Build the array values.
sliceVal := reflect.ValueOf(value)
arrayValues := make([]*bigqueryrestapi.QueryParameterValue, sliceVal.Len())
for i := 0; i < sliceVal.Len(); i++ {
arrayValues[i] = &bigqueryrestapi.QueryParameterValue{
Value: fmt.Sprintf("%v", sliceVal.Index(i).Interface()),
}
}
lowLevelParam.ParameterValue.ArrayValues = arrayValues
} else {
// Handle scalar types based on their defined type.
bqType, err := BQTypeStringFromToolType(p.GetType())
if err != nil {
return nil, err
}
lowLevelParam.ParameterType.Type = bqType
lowLevelParam.ParameterValue.Value = fmt.Sprintf("%v", value)
}
lowLevelParams = append(lowLevelParams, lowLevelParam)
}
query := t.Client.Query(newStatement)
query.Parameters = namedArgs
query.Parameters = highLevelParams
query.Location = t.Client.Location
dryRunJob, err := dryRunQuery(ctx, t.RestService, t.Client.Project(), t.Client.Location, newStatement, lowLevelParams, query.ConnectionProperties)
if err != nil {
// This is a fallback check in case the switch logic was bypassed.
return nil, fmt.Errorf("final query validation failed: %w", err)
}
statementType := dryRunJob.Statistics.Query.StatementType
// This block handles SELECT statements, which return a row set.
// We iterate through the results, convert each row into a map of
// column names to values, and return the collection of rows.
it, err := query.Read(ctx)
if err != nil {
return nil, fmt.Errorf("unable to execute query: %w", err)
@@ -177,7 +227,7 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
var out []any
for {
var row map[string]bigqueryapi.Value
err := it.Next(&row)
err = it.Next(&row)
if err == iterator.Done {
break
}
@@ -190,8 +240,21 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error)
}
out = append(out, vMap)
}
// If the query returned any rows, return them directly.
if len(out) > 0 {
return out, nil
}
return out, nil
// This handles the standard case for a SELECT query that successfully
// executes but returns zero rows.
if statementType == "SELECT" {
return "The query returned 0 rows.", nil
}
// This is the fallback for a successful query that doesn't return content.
// In most cases, this will be for DML/DDL statements like INSERT, UPDATE, CREATE, etc.
// However, it is also possible that this was a query that was expected to return rows
// but returned none, a case that we cannot distinguish here.
return "Query executed successfully and returned no content.", nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
@@ -209,3 +272,58 @@ func (t Tool) McpManifest() tools.McpManifest {
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func BQTypeStringFromToolType(toolType string) (string, error) {
switch toolType {
case "string":
return "STRING", nil
case "integer":
return "INT64", nil
case "float":
return "FLOAT64", nil
case "boolean":
return "BOOL", nil
// Note: 'array' is handled separately as it has a nested item type.
default:
return "", fmt.Errorf("unsupported tool parameter type for BigQuery: %s", toolType)
}
}
func dryRunQuery(
ctx context.Context,
restService *bigqueryrestapi.Service,
projectID string,
location string,
sql string,
params []*bigqueryrestapi.QueryParameter,
connProps []*bigqueryapi.ConnectionProperty,
) (*bigqueryrestapi.Job, error) {
useLegacySql := false
restConnProps := make([]*bigqueryrestapi.ConnectionProperty, len(connProps))
for i, prop := range connProps {
restConnProps[i] = &bigqueryrestapi.ConnectionProperty{Key: prop.Key, Value: prop.Value}
}
jobToInsert := &bigqueryrestapi.Job{
JobReference: &bigqueryrestapi.JobReference{
ProjectId: projectID,
Location: location,
},
Configuration: &bigqueryrestapi.JobConfiguration{
DryRun: true,
Query: &bigqueryrestapi.JobConfigurationQuery{
Query: sql,
UseLegacySql: &useLegacySql,
ConnectionProperties: restConnProps,
QueryParameters: params,
},
},
}
insertResponse, err := restService.Jobs.Insert(projectID, jobToInsert).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("failed to insert dry run job: %w", err)
}
return insertResponse, nil
}

View File

@@ -0,0 +1,183 @@
// 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 dataplexlookupentry
import (
"context"
"fmt"
dataplexapi "cloud.google.com/go/dataplex/apiv1"
dataplexpb "cloud.google.com/go/dataplex/apiv1/dataplexpb"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
dataplexds "github.com/googleapis/genai-toolbox/internal/sources/dataplex"
"github.com/googleapis/genai-toolbox/internal/tools"
)
const kind string = "dataplex-lookup-entry"
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 {
CatalogClient() *dataplexapi.CatalogClient
}
// validate compatible sources are still compatible
var _ compatibleSource = &dataplexds.Source{}
var compatibleSources = [...]string{dataplexds.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"`
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters `yaml:"parameters"`
}
// 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) {
// Initialize the search configuration with the provided sources
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)
}
viewDesc := `
## Argument: view
**Type:** Integer
**Description:** Specifies the parts of the entry and its aspects to return.
**Possible Values:**
* 1 (BASIC): Returns entry without aspects.
* 2 (FULL): Return all required aspects and the keys of non-required aspects. (Default)
* 3 (CUSTOM): Return the entry and aspects requested in aspect_types field (at most 100 aspects). Always use this view when aspect_types is not empty.
* 4 (ALL): Return the entry and both required and optional aspects (at most 100 aspects)
`
name := tools.NewStringParameter("name", "The project to which the request should be attributed in the following form: projects/{project}/locations/{location}.")
view := tools.NewIntParameterWithDefault("view", 2, viewDesc)
aspectTypes := tools.NewArrayParameterWithDefault("aspectTypes", []any{}, "Limits the aspects returned to the provided aspect types. It only works when used together with CUSTOM view.", tools.NewStringParameter("aspectType", "The types of aspects to be included in the response in the format `projects/{project}/locations/{location}/aspectTypes/{aspectType}`."))
entry := tools.NewStringParameter("entry", "The resource name of the Entry in the following form: projects/{project}/locations/{location}/entryGroups/{entryGroup}/entries/{entry}.")
parameters := tools.Parameters{name, view, aspectTypes, entry}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: parameters.McpManifest(),
}
t := &Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
AuthRequired: cfg.AuthRequired,
CatalogClient: s.CatalogClient(),
manifest: tools.Manifest{
Description: cfg.Description,
Parameters: parameters.Manifest(),
AuthRequired: cfg.AuthRequired,
},
mcpManifest: mcpManifest,
}
return t, nil
}
type Tool struct {
Name string
Kind string
Parameters tools.Parameters
AuthRequired []string
CatalogClient *dataplexapi.CatalogClient
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t *Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func (t *Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
paramsMap := params.AsMap()
viewMap := map[int]dataplexpb.EntryView{
1: dataplexpb.EntryView_BASIC,
2: dataplexpb.EntryView_FULL,
3: dataplexpb.EntryView_CUSTOM,
4: dataplexpb.EntryView_ALL,
}
name, _ := paramsMap["name"].(string)
entry, _ := paramsMap["entry"].(string)
view, _ := paramsMap["view"].(int)
aspectTypeSlice, err := tools.ConvertAnySliceToTyped(paramsMap["aspectTypes"].([]any), "string")
if err != nil {
return nil, fmt.Errorf("can't convert aspectTypes to array of strings: %s", err)
}
aspectTypes := aspectTypeSlice.([]string)
req := &dataplexpb.LookupEntryRequest{
Name: name,
View: viewMap[view],
AspectTypes: aspectTypes,
Entry: entry,
}
result, err := t.CatalogClient.LookupEntry(ctx, req)
if err != nil {
return nil, err
}
return result, nil
}
func (t *Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
// Parse parameters from the provided data
return tools.ParseParams(t.Parameters, data, claims)
}
func (t *Tool) Manifest() tools.Manifest {
// Returns the tool manifest
return t.manifest
}
func (t *Tool) McpManifest() tools.McpManifest {
// Returns the tool MCP manifest
return t.mcpManifest
}

View File

@@ -0,0 +1,117 @@
// 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 dataplexlookupentry_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/dataplex/dataplexlookupentry"
)
func TestParseFromYamlDataplexLookupEntry(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: dataplex-lookup-entry
source: my-instance
description: some description
`,
want: server.ToolConfigs{
"example_tool": dataplexlookupentry.Config{
Name: "example_tool",
Kind: "dataplex-lookup-entry",
Source: "my-instance",
Description: "some description",
AuthRequired: []string{},
},
},
},
{
desc: "advanced example",
in: `
tools:
example_tool:
kind: dataplex-lookup-entry
source: my-instance
description: some description
parameters:
- name: name
type: string
description: some name description
- name: view
type: string
description: some view description
- name: aspectTypes
type: array
description: some aspect types description
default: []
items:
name: aspectType
type: string
description: some aspect type description
- name: entry
type: string
description: some entry description
`,
want: server.ToolConfigs{
"example_tool": dataplexlookupentry.Config{
Name: "example_tool",
Kind: "dataplex-lookup-entry",
Source: "my-instance",
Description: "some description",
AuthRequired: []string{},
Parameters: []tools.Parameter{
tools.NewStringParameter("name", "some name description"),
tools.NewStringParameter("view", "some view description"),
tools.NewArrayParameterWithDefault("aspectTypes", []any{}, "some aspect types description", tools.NewStringParameter("aspectType", "some aspect type description")),
tools.NewStringParameter("entry", "some entry description"),
},
},
},
},
}
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

@@ -80,12 +80,11 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
}
query := tools.NewStringParameter("query", "The query against which entries in scope should be matched.")
name := tools.NewStringParameterWithDefault("name", fmt.Sprintf("projects/%s/locations/global", s.ProjectID()), "The project to which the request should be attributed in the following form: projects/{project}/locations/global")
pageSize := tools.NewIntParameterWithDefault("pageSize", 5, "Number of results in the search page.")
pageToken := tools.NewStringParameterWithDefault("pageToken", "", "Page token received from a previous locations.searchEntries call. Provide this to retrieve the subsequent page.")
orderBy := tools.NewStringParameterWithDefault("orderBy", "relevance", "Specifies the ordering of results. Supported values are: relevance, last_modified_timestamp, last_modified_timestamp asc")
semanticSearch := tools.NewBooleanParameterWithDefault("semanticSearch", true, "Whether to use semantic search for the query. If true, the query will be processed using semantic search capabilities.")
parameters := tools.Parameters{query, name, pageSize, pageToken, orderBy, semanticSearch}
parameters := tools.Parameters{query, pageSize, pageToken, orderBy, semanticSearch}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
@@ -93,7 +92,7 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
InputSchema: parameters.McpManifest(),
}
t := &SearchTool{
t := &Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
@@ -110,7 +109,7 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
return t, nil
}
type SearchTool struct {
type Tool struct {
Name string
Kind string
Parameters tools.Parameters
@@ -121,14 +120,13 @@ type SearchTool struct {
mcpManifest tools.McpManifest
}
func (t *SearchTool) Authorized(verifiedAuthServices []string) bool {
func (t *Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func (t *SearchTool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
func (t *Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
paramsMap := params.AsMap()
query, _ := paramsMap["query"].(string)
name, _ := paramsMap["name"].(string)
pageSize, _ := paramsMap["pageSize"].(int32)
pageToken, _ := paramsMap["pageToken"].(string)
orderBy, _ := paramsMap["orderBy"].(string)
@@ -136,7 +134,7 @@ func (t *SearchTool) Invoke(ctx context.Context, params tools.ParamValues) (any,
req := &dataplexpb.SearchEntriesRequest{
Query: query,
Name: name,
Name: fmt.Sprintf("projects/%s/locations/global", t.ProjectID),
PageSize: pageSize,
PageToken: pageToken,
OrderBy: orderBy,
@@ -159,17 +157,17 @@ func (t *SearchTool) Invoke(ctx context.Context, params tools.ParamValues) (any,
return results, nil
}
func (t *SearchTool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
func (t *Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
// Parse parameters from the provided data
return tools.ParseParams(t.Parameters, data, claims)
}
func (t *SearchTool) Manifest() tools.Manifest {
func (t *Tool) Manifest() tools.Manifest {
// Returns the tool manifest
return t.manifest
}
func (t *SearchTool) McpManifest() tools.McpManifest {
func (t *Tool) McpManifest() tools.McpManifest {
// Returns the tool MCP manifest
return t.mcpManifest
}

View File

@@ -0,0 +1,194 @@
// 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 tidbexecutesql
import (
"context"
"database/sql"
"fmt"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/sources/tidb"
"github.com/googleapis/genai-toolbox/internal/tools"
)
const kind string = "tidb-execute-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 {
TiDBPool() *sql.DB
}
// validate compatible sources are still compatible
var _ compatibleSource = &tidb.Source{}
var compatibleSources = [...]string{tidb.SourceKind}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
// validate interface
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
// verify source exists
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
// verify the source is compatible
s, ok := rawS.(compatibleSource)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
}
sqlParameter := tools.NewStringParameter("sql", "The sql to execute.")
parameters := tools.Parameters{sqlParameter}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: parameters.McpManifest(),
}
// finish tool setup
t := Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
AuthRequired: cfg.AuthRequired,
Pool: s.TiDBPool(),
manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}
return t, nil
}
// validate interface
var _ tools.Tool = Tool{}
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters `yaml:"parameters"`
Pool *sql.DB
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
sliceParams := params.AsSlice()
sql, ok := sliceParams[0].(string)
if !ok {
return nil, fmt.Errorf("unable to get cast %s", sliceParams[0])
}
results, err := t.Pool.QueryContext(ctx, sql)
if err != nil {
return nil, fmt.Errorf("unable to execute query: %w", err)
}
defer results.Close()
cols, err := results.Columns()
if err != nil {
return nil, fmt.Errorf("unable to retrieve rows column name: %w", err)
}
// create an array of values for each column, which can be re-used to scan each row
rawValues := make([]any, len(cols))
values := make([]any, len(cols))
for i := range rawValues {
values[i] = &rawValues[i]
}
colTypes, err := results.ColumnTypes()
if err != nil {
return nil, fmt.Errorf("unable to get column types: %w", err)
}
var out []any
for results.Next() {
err := results.Scan(values...)
if err != nil {
return nil, fmt.Errorf("unable to parse row: %w", err)
}
vMap := make(map[string]any)
for i, name := range cols {
val := rawValues[i]
if val == nil {
vMap[name] = nil
continue
}
// mysql driver return []uint8 type for "TEXT", "VARCHAR", and "NVARCHAR"
// we'll need to cast it back to string
switch colTypes[i].DatabaseTypeName() {
case "TEXT", "VARCHAR", "NVARCHAR":
vMap[name] = string(val.([]byte))
default:
vMap[name] = val
}
}
out = append(out, vMap)
}
if err := results.Err(); err != nil {
return nil, fmt.Errorf("errors encountered during row iteration: %w", err)
}
return out, nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.Parameters, 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)
}

View File

@@ -0,0 +1,76 @@
// 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 tidbexecutesql_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/tidb/tidbexecutesql"
)
func TestParseFromYamlExecuteSql(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
tools:
example_tool:
kind: tidb-execute-sql
source: my-instance
description: some description
authRequired:
- my-google-auth-service
- other-auth-service
`,
want: server.ToolConfigs{
"example_tool": tidbexecutesql.Config{
Name: "example_tool",
Kind: "tidb-execute-sql",
Source: "my-instance",
Description: "some description",
AuthRequired: []string{"my-google-auth-service", "other-auth-service"},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}

View File

@@ -0,0 +1,217 @@
// 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 tidbsql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/sources/tidb"
"github.com/googleapis/genai-toolbox/internal/tools"
)
const kind string = "tidb-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 {
TiDBPool() *sql.DB
}
// validate compatible sources are still compatible
var _ compatibleSource = &tidb.Source{}
var compatibleSources = [...]string{tidb.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 := tools.ProcessParameters(cfg.TemplateParameters, cfg.Parameters)
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.TiDBPool(),
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 *sql.DB
Statement string
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (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.QueryContext(ctx, newStatement, sliceParams...)
if err != nil {
return nil, fmt.Errorf("unable to execute query: %w", err)
}
cols, err := results.Columns()
if err != nil {
return nil, fmt.Errorf("unable to retrieve rows column name: %w", err)
}
// create an array of values for each column, which can be re-used to scan each row
rawValues := make([]any, len(cols))
values := make([]any, len(cols))
for i := range rawValues {
values[i] = &rawValues[i]
}
defer results.Close()
colTypes, err := results.ColumnTypes()
if err != nil {
return nil, fmt.Errorf("unable to get column types: %w", err)
}
var out []any
for results.Next() {
err := results.Scan(values...)
if err != nil {
return nil, fmt.Errorf("unable to parse row: %w", err)
}
vMap := make(map[string]any)
for i, name := range cols {
val := rawValues[i]
if val == nil {
vMap[name] = nil
continue
}
// mysql driver return []uint8 type for "TEXT", "VARCHAR", and "NVARCHAR"
// we'll need to cast it back to string
switch colTypes[i].DatabaseTypeName() {
case "JSON":
// unmarshal JSON data before storing to prevent double marshaling
var unmarshaledData any
err := json.Unmarshal(val.([]byte), &unmarshaledData)
if err != nil {
return nil, fmt.Errorf("unable to unmarshal json data %s", val)
}
vMap[name] = unmarshaledData
case "TEXT", "VARCHAR", "NVARCHAR":
vMap[name] = string(val.([]byte))
default:
vMap[name] = val
}
}
out = append(out, vMap)
}
if err := results.Err(); err != nil {
return nil, fmt.Errorf("errors encountered during row iteration: %w", err)
}
return out, nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.AllParams, data, claims)
}
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}

View File

@@ -0,0 +1,175 @@
// 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 tidbsql_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/tidb/tidbsql"
)
func TestParseFromYamlTiDB(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: tidb-sql
source: my-tidb-instance
description: some description
statement: |
SELECT * FROM SQL_STATEMENT;
authRequired:
- my-google-auth-service
- other-auth-service
parameters:
- name: country
type: string
description: some description
authServices:
- name: my-google-auth-service
field: user_id
- name: other-auth-service
field: user_id
`,
want: server.ToolConfigs{
"example_tool": tidbsql.Config{
Name: "example_tool",
Kind: "tidb-sql",
Source: "my-tidb-instance",
Description: "some description",
Statement: "SELECT * FROM SQL_STATEMENT;\n",
AuthRequired: []string{"my-google-auth-service", "other-auth-service"},
Parameters: []tools.Parameter{
tools.NewStringParameterWithAuth("country", "some description",
[]tools.ParamAuthService{{Name: "my-google-auth-service", Field: "user_id"},
{Name: "other-auth-service", Field: "user_id"}}),
},
},
},
},
}
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 TestParseFromYamlWithTemplateParamsTiDB(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: tidb-sql
source: my-tidb-instance
description: some description
statement: |
SELECT * FROM SQL_STATEMENT;
authRequired:
- my-google-auth-service
- other-auth-service
parameters:
- name: country
type: string
description: some description
authServices:
- name: my-google-auth-service
field: user_id
- name: other-auth-service
field: user_id
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": tidbsql.Config{
Name: "example_tool",
Kind: "tidb-sql",
Source: "my-tidb-instance",
Description: "some description",
Statement: "SELECT * FROM SQL_STATEMENT;\n",
AuthRequired: []string{"my-google-auth-service", "other-auth-service"},
Parameters: []tools.Parameter{
tools.NewStringParameterWithAuth("country", "some description",
[]tools.ParamAuthService{{Name: "my-google-auth-service", Field: "user_id"},
{Name: "other-auth-service", Field: "user_id"}}),
},
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

@@ -72,7 +72,7 @@ func initBigQueryConnection(project string) (*bigqueryapi.Client, error) {
func TestBigQueryToolEndpoints(t *testing.T) {
sourceConfig := getBigQueryVars(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var args []string
@@ -100,6 +100,11 @@ func TestBigQueryToolEndpoints(t *testing.T) {
datasetName,
strings.ReplaceAll(uuid.New().String(), "-", ""),
)
tableNameDataType := fmt.Sprintf("`%s.%s.datatype_table_%s`",
BigqueryProject,
datasetName,
strings.ReplaceAll(uuid.New().String(), "-", ""),
)
// set up data for param tool
createParamTableStmt, insertParamTableStmt, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, paramTestParams := getBigQueryParamToolInfo(tableNameParam)
@@ -111,8 +116,14 @@ func TestBigQueryToolEndpoints(t *testing.T) {
teardownTable2 := setupBigQueryTable(t, ctx, client, createAuthTableStmt, insertAuthTableStmt, datasetName, tableNameAuth, authTestParams)
defer teardownTable2(t)
// set up data for data type test tool
createDataTypeTableStmt, insertDataTypeTableStmt, dataTypeToolStmt, arrayDataTypeToolStmt, dataTypeTestParams := getBigQueryDataTypeTestInfo(tableNameDataType)
teardownTable3 := setupBigQueryTable(t, ctx, client, createDataTypeTableStmt, insertDataTypeTableStmt, datasetName, tableNameDataType, dataTypeTestParams)
defer teardownTable3(t)
// Write config into a file and pass it to command
toolsFile := tests.GetToolsConfig(sourceConfig, BigqueryToolKind, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, authToolStmt)
toolsFile = addBigQuerySqlToolConfig(t, toolsFile, dataTypeToolStmt, arrayDataTypeToolStmt)
toolsFile = addBigQueryPrebuiltToolsConfig(t, toolsFile)
tmplSelectCombined, tmplSelectFilterCombined := getBigQueryTmplToolStatement()
toolsFile = tests.AddTemplateParamConfig(t, toolsFile, BigqueryToolKind, tmplSelectCombined, tmplSelectFilterCombined, "")
@@ -135,18 +146,23 @@ func TestBigQueryToolEndpoints(t *testing.T) {
select1Want := "[{\"f0_\":1}]"
// Partial message; the full error message is too long.
failInvocationWant := `{"jsonrpc":"2.0","id":"invoke-fail-tool","result":{"content":[{"type":"text","text":"unable to execute query: googleapi: Error 400: Syntax error: Unexpected identifier \"SELEC\" at [1:1]`
failInvocationWant := `{"jsonrpc":"2.0","id":"invoke-fail-tool","result":{"content":[{"type":"text","text":"final query validation failed: failed to insert dry run job: googleapi: Error 400: Syntax error: Unexpected identifier \"SELEC\" at [1:1]`
datasetInfoWant := "\"Location\":\"US\",\"DefaultTableExpiration\":0,\"Labels\":null,\"Access\":"
tableInfoWant := "{\"Name\":\"\",\"Location\":\"US\",\"Description\":\"\",\"Schema\":[{\"Name\":\"id\""
ddlWant := `"Query executed successfully and returned no content."`
invokeParamWant, invokeIdNullWant, nullWant, mcpInvokeParamWant := tests.GetNonSpannerInvokeParamWant()
tests.RunToolInvokeTest(t, select1Want, invokeParamWant, invokeIdNullWant, nullWant, false, true)
tests.RunMCPToolCallMethod(t, mcpInvokeParamWant, failInvocationWant)
templateParamTestConfig := tests.NewTemplateParameterTestConfig(
tests.WithCreateColArray(`["id INT64", "name STRING", "age INT64"]`),
tests.WithDdlWant(ddlWant),
tests.WithSelectEmptyWant(`"The query returned 0 rows."`),
tests.WithInsert1Want(ddlWant),
)
tests.RunToolInvokeWithTemplateParameters(t, tableNameTemplateParam, templateParamTestConfig)
runBigQueryExecuteSqlToolInvokeTest(t, select1Want, invokeParamWant, tableNameParam)
runBigQueryExecuteSqlToolInvokeTest(t, select1Want, invokeParamWant, tableNameParam, ddlWant)
runBigQueryDataTypeTests(t)
runBigQueryListDatasetToolInvokeTest(t, datasetName)
runBigQueryGetDatasetInfoToolInvokeTest(t, datasetName, datasetInfoWant)
runBigQueryListTableIdsToolInvokeTest(t, datasetName, tableName)
@@ -187,6 +203,22 @@ func getBigQueryAuthToolInfo(tableName string) (string, string, string, []bigque
return createStatement, insertStatement, toolStatement, params
}
// getBigQueryDataTypeTestInfo returns statements and params for data type tests.
func getBigQueryDataTypeTestInfo(tableName string) (string, string, string, string, []bigqueryapi.QueryParameter) {
createStatement := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (id INT64, int_val INT64, string_val STRING, float_val FLOAT64, bool_val BOOL);`, tableName)
insertStatement := fmt.Sprintf(`
INSERT INTO %s (id, int_val, string_val, float_val, bool_val) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?);`, tableName)
toolStatement := fmt.Sprintf(`SELECT * FROM %s WHERE int_val = ? AND string_val = ? AND float_val = ? AND bool_val = ?;`, tableName)
arrayToolStatement := fmt.Sprintf(`SELECT * FROM %s WHERE int_val IN UNNEST(@int_array) AND string_val IN UNNEST(@string_array) AND float_val IN UNNEST(@float_array) AND bool_val IN UNNEST(@bool_array) ORDER BY id;`, tableName)
params := []bigqueryapi.QueryParameter{
{Value: int64(1)}, {Value: int64(123)}, {Value: "hello"}, {Value: 3.14}, {Value: true},
{Value: int64(2)}, {Value: int64(-456)}, {Value: "world"}, {Value: -0.55}, {Value: false},
{Value: int64(3)}, {Value: int64(789)}, {Value: "test"}, {Value: 100.1}, {Value: true},
}
return createStatement, insertStatement, toolStatement, arrayToolStatement, params
}
// getBigQueryTmplToolStatement returns statements for template parameter test cases for bigquery kind
func getBigQueryTmplToolStatement() (string, string) {
tmplSelectCombined := "SELECT * FROM {{.tableName}} WHERE id = ? ORDER BY id"
@@ -345,7 +377,41 @@ func addBigQueryPrebuiltToolsConfig(t *testing.T, config map[string]any) map[str
return config
}
func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamWant, tableNameParam string) {
func addBigQuerySqlToolConfig(t *testing.T, config map[string]any, toolStatement, arrayToolStatement string) map[string]any {
tools, ok := config["tools"].(map[string]any)
if !ok {
t.Fatalf("unable to get tools from config")
}
tools["my-scalar-datatype-tool"] = map[string]any{
"kind": "bigquery-sql",
"source": "my-instance",
"description": "Tool to test various scalar data types.",
"statement": toolStatement,
"parameters": []any{
map[string]any{"name": "int_val", "type": "integer", "description": "an integer value"},
map[string]any{"name": "string_val", "type": "string", "description": "a string value"},
map[string]any{"name": "float_val", "type": "float", "description": "a float value"},
map[string]any{"name": "bool_val", "type": "boolean", "description": "a boolean value"},
},
}
tools["my-array-datatype-tool"] = map[string]any{
"kind": "bigquery-sql",
"source": "my-instance",
"description": "Tool to test various array data types.",
"statement": arrayToolStatement,
"parameters": []any{
map[string]any{"name": "int_array", "type": "array", "description": "an array of integer values", "items": map[string]any{"name": "item", "type": "integer", "description": "desc"}},
map[string]any{"name": "string_array", "type": "array", "description": "an array of string values", "items": map[string]any{"name": "item", "type": "string", "description": "desc"}},
map[string]any{"name": "float_array", "type": "array", "description": "an array of float values", "items": map[string]any{"name": "item", "type": "float", "description": "desc"}},
map[string]any{"name": "bool_array", "type": "array", "description": "an array of boolean values", "items": map[string]any{"name": "item", "type": "boolean", "description": "desc"}},
},
}
config["tools"] = tools
return config
}
func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamWant, tableNameParam, ddlWant string) {
// Get ID token
idToken, err := tests.GetGoogleIdToken(tests.ClientId)
if err != nil {
@@ -381,7 +447,7 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"sql":"CREATE TABLE t (id SERIAL PRIMARY KEY, name TEXT)"}`)),
want: `"Operation completed successfully."`,
want: ddlWant,
isErr: true,
},
{
@@ -405,7 +471,7 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"sql":"DROP TABLE t"}`)),
want: `"Operation completed successfully."`,
want: ddlWant,
isErr: true,
},
{
@@ -413,7 +479,7 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"sql\":\"INSERT INTO %s (id, name) VALUES (4, 'test_name')\"}", tableNameParam))),
want: `"Operation completed successfully."`,
want: ddlWant,
isErr: false,
},
{
@@ -490,6 +556,84 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
}
}
func runBigQueryDataTypeTests(t *testing.T) {
// Test tool invoke endpoint
invokeTcs := []struct {
name string
api string
requestHeader map[string]string
requestBody io.Reader
want string
isErr bool
}{
{
name: "invoke my-scalar-datatype-tool with values",
api: "http://127.0.0.1:5000/api/tool/my-scalar-datatype-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"int_val": 123, "string_val": "hello", "float_val": 3.14, "bool_val": true}`)),
want: `[{"bool_val":true,"float_val":3.14,"id":1,"int_val":123,"string_val":"hello"}]`,
isErr: false,
},
{
name: "invoke my-scalar-datatype-tool with missing params",
api: "http://127.0.0.1:5000/api/tool/my-scalar-datatype-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"int_val": 123}`)),
isErr: true,
},
{
name: "invoke my-array-datatype-tool",
api: "http://127.0.0.1:5000/api/tool/my-array-datatype-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"int_array": [123, 789], "string_array": ["hello", "test"], "float_array": [3.14, 100.1], "bool_array": [true]}`)),
want: `[{"bool_val":true,"float_val":3.14,"id":1,"int_val":123,"string_val":"hello"},{"bool_val":true,"float_val":100.1,"id":3,"int_val":789,"string_val":"test"}]`,
isErr: false,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
// Send Tool invocation request
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")
for k, v := range tc.requestHeader {
req.Header.Add(k, v)
}
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))
}
// Check response body
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body")
}
got, ok := body["result"].(string)
if !ok {
t.Fatalf("unable to find result in response body")
}
if got != tc.want {
t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
}
})
}
}
func runBigQueryListDatasetToolInvokeTest(t *testing.T, datasetWant string) {
// Get ID token
idToken, err := tests.GetGoogleIdToken(tests.ClientId)

View File

@@ -19,6 +19,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
@@ -39,6 +40,7 @@ import (
var (
DataplexSourceKind = "dataplex"
DataplexSearchEntriesToolKind = "dataplex-search-entries"
DataplexLookupEntryToolKind = "dataplex-lookup-entry"
DataplexProject = os.Getenv("DATAPLEX_PROJECT")
)
@@ -69,7 +71,7 @@ func initBigQueryConnection(ctx context.Context, project string) (*bigqueryapi.C
func TestDataplexToolEndpoints(t *testing.T) {
sourceConfig := getDataplexVars(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
var args []string
@@ -94,7 +96,7 @@ func TestDataplexToolEndpoints(t *testing.T) {
}
defer cleanup()
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
waitCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
if err != nil {
@@ -102,8 +104,9 @@ func TestDataplexToolEndpoints(t *testing.T) {
t.Fatalf("toolbox didn't start successfully: %s", err)
}
runDataplexSearchEntriesToolGetTest(t)
runDataplexToolGetTest(t)
runDataplexSearchEntriesToolInvokeTest(t, tableName, datasetName)
runDataplexLookupEntryToolInvokeTest(t, tableName, datasetName)
}
func setupBigQueryTable(t *testing.T, ctx context.Context, client *bigqueryapi.Client, datasetName string, tableName string) func(*testing.T) {
@@ -169,92 +172,169 @@ func getDataplexToolsConfig(sourceConfig map[string]any) map[string]any {
"sources": map[string]any{
"my-dataplex-instance": sourceConfig,
},
"authServices": map[string]any{
"my-google-auth": map[string]any{
"kind": "google",
"clientId": tests.ClientId,
},
},
"tools": map[string]any{
"my-search-entries-tool": map[string]any{
"my-dataplex-search-entries-tool": map[string]any{
"kind": DataplexSearchEntriesToolKind,
"source": "my-dataplex-instance",
"description": "Simple tool to test end to end functionality.",
},
"my-auth-dataplex-search-entries-tool": map[string]any{
"kind": DataplexSearchEntriesToolKind,
"source": "my-dataplex-instance",
"description": "Simple tool to test end to end functionality.",
"authRequired": []string{"my-google-auth"},
},
"my-dataplex-lookup-entry-tool": map[string]any{
"kind": DataplexLookupEntryToolKind,
"source": "my-dataplex-instance",
"description": "Simple dataplex lookup entry tool to test end to end functionality.",
},
"my-auth-dataplex-lookup-entry-tool": map[string]any{
"kind": DataplexLookupEntryToolKind,
"source": "my-dataplex-instance",
"description": "Simple dataplex lookup entry tool to test end to end functionality.",
"authRequired": []string{"my-google-auth"},
},
},
}
return toolsFile
}
func runDataplexSearchEntriesToolGetTest(t *testing.T) {
resp, err := http.Get("http://127.0.0.1:5000/api/tool/my-search-entries-tool/")
if err != nil {
t.Fatalf("error making GET request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected status code 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("error decoding response body: %s", err)
}
got, ok := body["tools"]
if !ok {
t.Fatalf("unable to find 'tools' key in response body")
func runDataplexToolGetTest(t *testing.T) {
testCases := []struct {
name string
toolName string
expectedParams []string
}{
{
name: "get my-dataplex-search-entries-tool",
toolName: "my-dataplex-search-entries-tool",
expectedParams: []string{"pageSize", "pageToken", "query", "orderBy", "semanticSearch"},
},
{
name: "get my-dataplex-lookup-entry-tool",
toolName: "my-dataplex-lookup-entry-tool",
expectedParams: []string{"name", "view", "aspectTypes", "entry"},
},
}
toolsMap, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("tools is not a map")
}
tool, ok := toolsMap["my-search-entries-tool"].(map[string]interface{})
if !ok {
t.Fatalf("tool not found in manifest")
}
params, ok := tool["parameters"].([]interface{})
if !ok {
t.Fatalf("parameters not found")
}
paramNames := []string{}
for _, param := range params {
paramMap, ok := param.(map[string]interface{})
if ok {
paramNames = append(paramNames, paramMap["name"].(string))
}
}
expected := []string{"name", "pageSize", "pageToken", "orderBy", "query"}
for _, want := range expected {
found := false
for _, got := range paramNames {
if got == want {
found = true
break
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/", tc.toolName))
if err != nil {
t.Fatalf("error when sending a request: %s", err)
}
}
if !found {
t.Fatalf("expected parameter %q not found in tool parameters", want)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("response status code is not 200")
}
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body")
}
got, ok := body["tools"]
if !ok {
t.Fatalf("unable to find tools in response body")
}
toolsMap, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected 'tools' to be a map, got %T", got)
}
tool, ok := toolsMap[tc.toolName].(map[string]interface{})
if !ok {
t.Fatalf("expected tool %q to be a map, got %T", tc.toolName, toolsMap[tc.toolName])
}
params, ok := tool["parameters"].([]interface{})
if !ok {
t.Fatalf("expected 'parameters' to be a slice, got %T", tool["parameters"])
}
paramSet := make(map[string]struct{})
for _, param := range params {
paramMap, ok := param.(map[string]interface{})
if ok {
if name, ok := paramMap["name"].(string); ok {
paramSet[name] = struct{}{}
}
}
}
var missing []string
for _, want := range tc.expectedParams {
if _, found := paramSet[want]; !found {
missing = append(missing, want)
}
}
if len(missing) > 0 {
t.Fatalf("missing parameters for tool %q: %v", tc.toolName, missing)
}
})
}
}
func runDataplexSearchEntriesToolInvokeTest(t *testing.T, tableName string, datasetName string) {
idToken, err := tests.GetGoogleIdToken(tests.ClientId)
if err != nil {
t.Fatalf("error getting Google ID token: %s", err)
}
testCases := []struct {
name string
tableName string
datasetName string
api string
requestHeader map[string]string
requestBody io.Reader
wantStatusCode int
expectResult bool
wantContentKey string
}{
{
name: "Success - Entry Found",
tableName: tableName,
datasetName: datasetName,
api: "http://127.0.0.1:5000/api/tool/my-dataplex-search-entries-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"query\":\"displayname=%s system=bigquery parent=%s\"}", tableName, datasetName))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "dataplex_entry",
},
{
name: "Success with Authorization - Entry Found",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-search-entries-tool/invoke",
requestHeader: map[string]string{"my-google-auth_token": idToken},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"query\":\"displayname=%s system=bigquery parent=%s\"}", tableName, datasetName))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "dataplex_entry",
},
{
name: "Failure - Invalid Authorization Token",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-search-entries-tool/invoke",
requestHeader: map[string]string{"my-google-auth_token": "invalid_token"},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"query\":\"displayname=%s system=bigquery parent=%s\"}", tableName, datasetName))),
wantStatusCode: 401,
expectResult: false,
wantContentKey: "dataplex_entry",
},
{
name: "Failure - Without Authorization Token",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-search-entries-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"query\":\"displayname=%s system=bigquery parent=%s\"}", tableName, datasetName))),
wantStatusCode: 401,
expectResult: false,
wantContentKey: "dataplex_entry",
},
{
name: "Failure - Entry Not Found",
tableName: "",
datasetName: "",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-search-entries-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"query":"displayname=\"\" system=bigquery parent=\"\""}`)),
wantStatusCode: 200,
expectResult: false,
wantContentKey: "",
@@ -263,19 +343,23 @@ func runDataplexSearchEntriesToolInvokeTest(t *testing.T, tableName string, data
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
query := fmt.Sprintf("displayname=\"%s\" system=bigquery parent:\"%s\"", tc.tableName, tc.datasetName)
reqBodyMap := map[string]string{"query": query}
reqBodyBytes, err := json.Marshal(reqBodyMap)
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
if err != nil {
t.Fatalf("error marshalling request body: %s", err)
t.Fatalf("unable to create request: %s", err)
}
resp, err := http.Post("http://127.0.0.1:5000/api/tool/my-search-entries-tool/invoke", "application/json", bytes.NewBuffer(reqBodyBytes))
req.Header.Add("Content-type", "application/json")
for k, v := range tc.requestHeader {
req.Header.Add(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("error making POST request: %s", err)
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatusCode {
t.Fatalf("response status code is not %d.", tc.wantStatusCode)
t.Fatalf("response status code is not %d. It is %d", tc.wantStatusCode, resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("Response body: %s", string(bodyBytes))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -297,8 +381,8 @@ func runDataplexSearchEntriesToolInvokeTest(t *testing.T, tableName string, data
}
if tc.expectResult {
if len(entries) == 0 {
t.Fatal("expected at least one entry, but got 0")
if len(entries) != 1 {
t.Fatalf("expected exactly one entry, but got %d", len(entries))
}
entry, ok := entries[0].(map[string]interface{})
if !ok {
@@ -315,3 +399,163 @@ func runDataplexSearchEntriesToolInvokeTest(t *testing.T, tableName string, data
})
}
}
func runDataplexLookupEntryToolInvokeTest(t *testing.T, tableName string, datasetName string) {
idToken, err := tests.GetGoogleIdToken(tests.ClientId)
if err != nil {
t.Fatalf("error getting Google ID token: %s", err)
}
testCases := []struct {
name string
wantStatusCode int
api string
requestHeader map[string]string
requestBody io.Reader
expectResult bool
wantContentKey string
dontWantContentKey string
aspectCheck bool
reqBodyMap map[string]any
}{
{
name: "Success - Entry Found",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s\"}", DataplexProject, DataplexProject, DataplexProject, datasetName))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "name",
},
{
name: "Success - Entry Found with Authorization",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{"my-google-auth_token": idToken},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s\"}", DataplexProject, DataplexProject, DataplexProject, datasetName))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "name",
},
{
name: "Failure - Invalid Authorization Token",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{"my-google-auth_token": "invalid_token"},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s\"}", DataplexProject, DataplexProject, DataplexProject, datasetName))),
wantStatusCode: 401,
expectResult: false,
wantContentKey: "name",
},
{
name: "Failure - Without Authorization Token",
api: "http://127.0.0.1:5000/api/tool/my-auth-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s\"}", DataplexProject, DataplexProject, DataplexProject, datasetName))),
wantStatusCode: 401,
expectResult: false,
wantContentKey: "name",
},
{
name: "Failure - Entry Not Found or Permission Denied",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s\"}", DataplexProject, DataplexProject, DataplexProject, "non-existent-dataset"))),
wantStatusCode: 400,
expectResult: false,
},
{
name: "Success - Entry Found with Basic View",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s/tables/%s\", \"view\": %d}", DataplexProject, DataplexProject, DataplexProject, datasetName, tableName, 1))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "name",
dontWantContentKey: "aspects",
},
{
name: "Failure - Entry with Custom View without Aspect Types",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s/tables/%s\", \"view\": %d}", DataplexProject, DataplexProject, DataplexProject, datasetName, tableName, 3))),
wantStatusCode: 400,
expectResult: false,
},
{
name: "Success - Entry Found with only Schema Aspect",
api: "http://127.0.0.1:5000/api/tool/my-dataplex-lookup-entry-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf("{\"name\":\"projects/%s/locations/us\", \"entry\":\"projects/%s/locations/us/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/%s/datasets/%s/tables/%s\", \"aspectTypes\":[\"projects/dataplex-types/locations/global/aspectTypes/schema\"], \"view\": %d}", DataplexProject, DataplexProject, DataplexProject, datasetName, tableName, 3))),
wantStatusCode: 200,
expectResult: true,
wantContentKey: "aspects",
aspectCheck: true,
},
}
for _, tc := range testCases {
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")
for k, v := range tc.requestHeader {
req.Header.Add(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatusCode {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("Response status code got %d, want %d\nResponse body: %s", resp.StatusCode, tc.wantStatusCode, string(bodyBytes))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Error parsing response body: %v", err)
}
if tc.expectResult {
resultStr, ok := result["result"].(string)
if !ok {
t.Fatalf("Expected 'result' field to be a string on success, got %T", result["result"])
}
if resultStr == "" || resultStr == "{}" || resultStr == "null" {
t.Fatal("Expected an entry, but got empty result")
}
var entry map[string]interface{}
if err := json.Unmarshal([]byte(resultStr), &entry); err != nil {
t.Fatalf("Error unmarshalling result string into entry map: %v", err)
}
if _, ok := entry[tc.wantContentKey]; !ok {
t.Fatalf("Expected entry to have key '%s', but it was not found in %v", tc.wantContentKey, entry)
}
if _, ok := entry[tc.dontWantContentKey]; ok {
t.Fatalf("Expected entry to not have key '%s', but it was found in %v", tc.dontWantContentKey, entry)
}
if tc.aspectCheck {
// Check length of aspects
aspects, ok := entry["aspects"].(map[string]interface{})
if !ok {
t.Fatalf("Expected 'aspects' to be a map, got %T", aspects)
}
if len(aspects) != 1 {
t.Fatalf("Expected exactly one aspect, but got %d", len(aspects))
}
}
} else { // Handle expected error response
_, ok := result["error"]
if !ok {
t.Fatalf("Expected 'error' field in response, got %v", result)
}
}
})
}
}

View File

@@ -0,0 +1,164 @@
// 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 tidb
import (
"context"
"database/sql"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
)
var (
TiDBSourceKind = "tidb"
TiDBToolKind = "tidb-sql"
TiDBDatabase = os.Getenv("TIDB_DATABASE")
TiDBHost = os.Getenv("TIDB_HOST")
TiDBPort = os.Getenv("TIDB_PORT")
TiDBUser = os.Getenv("TIDB_USER")
TiDBPass = os.Getenv("TIDB_PASS")
)
func getTiDBVars(t *testing.T) map[string]any {
switch "" {
case TiDBDatabase:
t.Fatal("'TIDB_DATABASE' not set")
case TiDBHost:
t.Fatal("'TIDB_HOST' not set")
case TiDBPort:
t.Fatal("'TIDB_PORT' not set")
case TiDBUser:
t.Fatal("'TIDB_USER' not set")
case TiDBPass:
t.Fatal("'TIDB_PASS' not set")
}
return map[string]any{
"kind": TiDBSourceKind,
"host": TiDBHost,
"port": TiDBPort,
"database": TiDBDatabase,
"user": TiDBUser,
"password": TiDBPass,
}
}
// Copied over from tidb.go
func initTiDBConnectionPool(host, port, user, pass, dbname string, useSSL bool) (*sql.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&tls=%t", user, pass, host, port, dbname, useSSL)
// Interact with the driver directly as you normally would
pool, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("sql.Open: %w", err)
}
return pool, nil
}
// getTiDBWants return the expected wants for tidb
func getTiDBWants() (string, string, string) {
select1Want := "[{\"1\":1}]"
failInvocationWant := `{"jsonrpc":"2.0","id":"invoke-fail-tool","result":{"content":[{"type":"text","text":"unable to execute query: Error 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your TiDB version for the right syntax to use line 1 column 5 near \"SELEC 1;\" "}],"isError":true}}`
createTableStatement := `"CREATE TABLE t (id SERIAL PRIMARY KEY, name TEXT)"`
return select1Want, failInvocationWant, createTableStatement
}
// addTiDBExecuteSqlConfig gets the tools config for `tidb-execute-sql`
func addTiDBExecuteSqlConfig(t *testing.T, config map[string]any) map[string]any {
tools, ok := config["tools"].(map[string]any)
if !ok {
t.Fatalf("unable to get tools from config")
}
tools["my-exec-sql-tool"] = map[string]any{
"kind": "tidb-execute-sql",
"source": "my-instance",
"description": "Tool to execute sql",
}
tools["my-auth-exec-sql-tool"] = map[string]any{
"kind": "tidb-execute-sql",
"source": "my-instance",
"description": "Tool to execute sql",
"authRequired": []string{
"my-google-auth",
},
}
config["tools"] = tools
return config
}
func TestTiDBToolEndpoints(t *testing.T) {
sourceConfig := getTiDBVars(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
var args []string
pool, err := initTiDBConnectionPool(TiDBHost, TiDBPort, TiDBUser, TiDBPass, TiDBDatabase, false)
if err != nil {
t.Fatalf("unable to create TiDB connection pool: %s", err)
}
// create table name with UUID
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(), "-", "")
// set up data for param tool
createParamTableStmt, insertParamTableStmt, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, paramTestParams := tests.GetMySQLParamToolInfo(tableNameParam)
teardownTable1 := tests.SetupMySQLTable(t, ctx, pool, createParamTableStmt, insertParamTableStmt, tableNameParam, paramTestParams)
defer teardownTable1(t)
// set up data for auth tool
createAuthTableStmt, insertAuthTableStmt, authToolStmt, authTestParams := tests.GetMySQLAuthToolInfo(tableNameAuth)
teardownTable2 := tests.SetupMySQLTable(t, ctx, pool, createAuthTableStmt, insertAuthTableStmt, tableNameAuth, authTestParams)
defer teardownTable2(t)
// Write config into a file and pass it to command
toolsFile := tests.GetToolsConfig(sourceConfig, TiDBToolKind, paramToolStmt, idParamToolStmt, nameParamToolStmt, arrayToolStmt, authToolStmt)
toolsFile = addTiDBExecuteSqlConfig(t, toolsFile)
tmplSelectCombined, tmplSelectFilterCombined := tests.GetMySQLTmplToolStatement()
toolsFile = tests.AddTemplateParamConfig(t, toolsFile, TiDBToolKind, 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)
}
tests.RunToolGetTest(t)
select1Want, failInvocationWant, createTableStatement := getTiDBWants()
invokeParamWant, invokeIdNullWant, nullWant, mcpInvokeParamWant := tests.GetNonSpannerInvokeParamWant()
tests.RunToolInvokeTest(t, select1Want, invokeParamWant, invokeIdNullWant, nullWant, true, false)
tests.RunExecuteSqlToolInvokeTest(t, createTableStatement, select1Want)
tests.RunMCPToolCallMethod(t, mcpInvokeParamWant, failInvocationWant)
tests.RunToolInvokeWithTemplateParameters(t, tableNameTemplateParam, tests.NewTemplateParameterTestConfig())
}

View File

@@ -405,14 +405,16 @@ func RunToolInvokeTest(t *testing.T, select1Want, invokeParamWant, invokeIdNullW
// TemplateParameterTestConfig represents the various configuration options for template parameter tests.
type TemplateParameterTestConfig struct {
ignoreDdl bool
ignoreInsert bool
selectAllWant string
select1Want string
nameFieldArray string
nameColFilter string
createColArray string
insert1Want string
ignoreDdl bool
ignoreInsert bool
ddlWant string
selectAllWant string
select1Want string
selectEmptyWant string
nameFieldArray string
nameColFilter string
createColArray string
insert1Want string
}
type Option func(*TemplateParameterTestConfig)
@@ -431,6 +433,13 @@ func WithIgnoreInsert() Option {
}
}
// WithDdlWant is the option function to configure ddlWant.
func WithDdlWant(s string) Option {
return func(c *TemplateParameterTestConfig) {
c.ddlWant = s
}
}
// WithSelectAllWant is the option function to configure selectAllWant.
func WithSelectAllWant(s string) Option {
return func(c *TemplateParameterTestConfig) {
@@ -445,6 +454,13 @@ func WithSelect1Want(s string) Option {
}
}
// WithSelectEmptyWant is the option function to configure selectEmptyWant.
func WithSelectEmptyWant(s string) Option {
return func(c *TemplateParameterTestConfig) {
c.selectEmptyWant = s
}
}
// WithReplaceNameFieldArray is the option function to configure replaceNameFieldArray.
func WithReplaceNameFieldArray(s string) Option {
return func(c *TemplateParameterTestConfig) {
@@ -475,14 +491,16 @@ func WithInsert1Want(s string) Option {
// NewTemplateParameterTestConfig creates a new TemplateParameterTestConfig instances with options.
func NewTemplateParameterTestConfig(options ...Option) *TemplateParameterTestConfig {
templateParamTestOption := &TemplateParameterTestConfig{
ignoreDdl: false,
ignoreInsert: false,
selectAllWant: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"},{\"age\":100,\"id\":2,\"name\":\"Alice\"}]",
select1Want: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"}]",
nameFieldArray: `["name"]`,
nameColFilter: "name",
createColArray: `["id INT","name VARCHAR(20)","age INT"]`,
insert1Want: "null",
ignoreDdl: false,
ignoreInsert: false,
ddlWant: "null",
selectAllWant: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"},{\"age\":100,\"id\":2,\"name\":\"Alice\"}]",
select1Want: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"}]",
selectEmptyWant: "null",
nameFieldArray: `["name"]`,
nameColFilter: "name",
createColArray: `["id INT","name VARCHAR(20)","age INT"]`,
insert1Want: "null",
}
// Apply provided options
@@ -514,7 +532,7 @@ func RunToolInvokeWithTemplateParameters(t *testing.T, tableName string, config
api: "http://127.0.0.1:5000/api/tool/create-table-templateParams-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s", "columns":%s}`, tableName, config.createColArray))),
want: "null",
want: config.ddlWant,
isErr: false,
},
{
@@ -551,6 +569,14 @@ func RunToolInvokeWithTemplateParameters(t *testing.T, tableName string, config
want: config.select1Want,
isErr: false,
},
{
name: "invoke select-templateParams-combined-tool with no results",
api: "http://127.0.0.1:5000/api/tool/select-templateParams-combined-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"id": 999, "tableName": "%s"}`, tableName))),
want: config.selectEmptyWant,
isErr: false,
},
{
name: "invoke select-fields-templateParams-tool",
api: "http://127.0.0.1:5000/api/tool/select-fields-templateParams-tool/invoke",
@@ -573,7 +599,7 @@ func RunToolInvokeWithTemplateParameters(t *testing.T, tableName string, config
api: "http://127.0.0.1:5000/api/tool/drop-table-templateParams-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s"}`, tableName))),
want: "null",
want: config.ddlWant,
isErr: false,
},
}