Compare commits

...

17 Commits

Author SHA1 Message Date
Prerna Kakkar
e2ec345e4a Merge branch 'main' into envvariable 2025-07-16 11:50:21 +00:00
prernakakkar-google
ed5ef4caea feat: Create wait for tool (#885)
`
tools:
  wait_for_tool:
    kind: wait-for
description: "Tool puts chat to sleep for given timeout example 3m,10s,
5m etc. For tasks such as cluster creation no need to wait. It takes
longer time such as 8m for instance creation. Use timeout value as
default if nothing is provided"
    timeout: 10s
`

Output: 
`
prernakakkar@prernakakkar:~/senseai/genai-toolbox$ curl -X POST -H
"Content-Type: application/json" -d '{"duration": "5m"}'
http://127.0.0.1:5000/api/tool/wa
it_for_tool/invoke
{"result":"[\"Wait for 5m0s completed successfully.\"]"}
`
2025-07-16 17:18:24 +05:30
Prerna Kakkar
6f569bc950 [feat]: Create tool to update mcp settings file for data plane with relevant env variables for alloydb 2025-07-16 07:54:42 +00:00
Yuan Teoh
208df0a428 docs: add mcp v2025-06-18 to supported mcp version (#899)
Add `2025-06-18` to list of supported MCP versions. Sort list to latest
version first.
2025-07-15 22:16:51 +00:00
Yuan Teoh
313d3ca0d0 feat(server/mcp): support MCP version 2025-06-18 (#898)
This PR add supports to MCP version 2025-06-18 defined
[here](https://modelcontextprotocol.io/specification/2025-06-18).

The main updates includes:
* Retrieving protocol version from header via `MCP-Protocol-Version`.
* Throwing `400 Bad Request` when an invalid version is received.
2025-07-15 15:07:27 -07:00
Jack Wotherspoon
4dae5a6ed7 chore: add medium badge to README that links to Toolbox Publication (#897)
Link to [Medium Publication for
Toolbox](https://medium.com/@mcp_toolbox) from badge.

<img width="819" height="216" alt="image"
src="https://github.com/user-attachments/assets/b6631c64-7bfa-4136-8b1c-3b29aeb118b3"
/>
2025-07-15 11:52:29 -06:00
Twisha Bansal
26bdba46ca docs: add links to quickstart (#888) 2025-07-15 11:47:59 +05:30
Yuan Teoh
a817b120ca feat: add support for null optional parameter (#802)
Added an option for user to indicate if the parameter is required. 

Example:
```
parameters:
  - name: foo
    type: string
    description: foo
    required: false
```

If the `required` field is not provided, it will be defaulted to `true`.


If a `default` value is provided, `required = false` regardless if the
field is indicated.
```
parameters:
  - name: foo
    type: string
    description: foo
    default: hello world
```

Fixes #736
2025-07-14 21:41:49 -07:00
Dr. Strangelove
8ce311f256 fix(server/api): add logger to context in tool invoke handler (#891) 2025-07-14 21:02:10 -07:00
Yuan Teoh
86227e3104 docs: update mcp docs to include v2025-03-26 (#868) 2025-07-14 15:38:30 -07:00
Nick Acosta
206bea4575 docs: update MCP inspector instructions (#867)
This pr updates the docs, specifically the quickstart working with MCP
Inspector. The tool recently included a Session Token for Authorization
that can easily be missed, this updates the guide to make it easy to
find and use this Session Token

---------

Co-authored-by: Nick Acosta <nick.acosta@getcollate.io>
Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
2025-07-14 20:11:28 +00:00
Harsh Jha
65843621c5 docs: Add JS SDK quickstart (#845)
Co-authored-by: Anmol Shukla <shuklaanmol@google.com>
Co-authored-by: Twisha Bansal <58483338+twishabansal@users.noreply.github.com>
Co-authored-by: Twisha Bansal <twishabansal@google.com>
2025-07-15 01:09:18 +05:30
Harsh Jha
e681a7e36c docs: clarify Bigtable GoogleSQL DML support (#863)
feat(docs): Enhance Bigtable GoogleSQL DML clarity

**Description:**
This PR updates the `bigtable-sql` tool documentation to provide a
clearer understanding of GoogleSQL's DML capabilities within Bigtable.

**Changes Made:**
- Added a note indicating that Bigtable's GoogleSQL dialect only
supports `SELECT` DML operations.
- Explicitly states that `INSERT`, `UPDATE`, and `DELETE` DML statements
are not supported.
- Updated the external link to directly reference the "Use cases"
section in the official Google Cloud Bigtable GoogleSQL overview, where
this limitation is detailed.

**Reasoning:**
The previous documentation, while linking to the overview, did not
explicitly highlight the DML limitations. This clarification will
prevent potential confusion for users expecting full DML support,
ensuring the documentation accurately reflects Bigtable's capabilities
with GoogleSQL.

---------

Co-authored-by: Twisha Bansal <58483338+twishabansal@users.noreply.github.com>
2025-07-14 23:32:05 +05:30
Jack Wotherspoon
0d1cadb245 chore: add docs readme badge and update discord to match style (#884)
Adding badge that links to docs to make them more easily discoverable
from README.
2025-07-14 10:54:02 -04:00
Harsh Jha
6d27dabfb2 docs: add optional cloud setup section for Vertex AI (#790)
Co-authored-by: Twisha Bansal <58483338+twishabansal@users.noreply.github.com>
2025-07-14 18:38:25 +05:30
dishaprakash
f312fc01b2 docs: Add Toolbox Go SDK snippets to the docsite (#851)
This PR adds the Go SDK code snippets to the docsite, following the same
pattern as Python and JS.

The code snippets are for:
- Core usage
- LangChain Go
- Genkit Go
- Go GenAI
- OpenAI Go
2025-07-14 02:28:45 +05:30
dishaprakash
4998cae260 docs: Add redirection links to client SDK content pages (#856)
This PR adds redirection links to the actual SDK content pages.
This is added because Hugo still generates a regular HTML site page for
each of the client SDK file, even though it points to the external
Github link.
In case a user finds themselves in the internal url pointing to these
files, this should still redirect them to the specific github repos.
2025-07-14 01:21:25 +05:30
31 changed files with 2471 additions and 64 deletions

View File

@@ -20,6 +20,7 @@ extraFiles: [
"README.md",
"docs/en/getting-started/introduction/_index.md",
"docs/en/getting-started/local_quickstart.md",
"docs/en/getting-started/local_quickstart_js.md",
"docs/en/getting-started/mcp_quickstart/_index.md",
"docs/en/samples/bigquery/local_quickstart.md",
"docs/en/samples/bigquery/mcp_quickstart/_index.md",

View File

@@ -2,7 +2,9 @@
# MCP Toolbox for Databases
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/Dmm69peqjh)
[![Docs](https://img.shields.io/badge/docs-MCP_Toolbox-blue)](https://googleapis.github.io/genai-toolbox/)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=flat&logo=discord&logoColor=white)](https://discord.gg/Dmm69peqjh)
[![Medium](https://img.shields.io/badge/Medium-12100E?style=flat&logo=medium&logoColor=white)](https://medium.com/@mcp_toolbox)
[![Go Report Card](https://goreportcard.com/badge/github.com/googleapis/genai-toolbox)](https://goreportcard.com/report/github.com/googleapis/genai-toolbox)
> [!NOTE]

View File

@@ -64,6 +64,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/utility/envvariable"
_ "github.com/googleapis/genai-toolbox/internal/tools/utility/wait"
_ "github.com/googleapis/genai-toolbox/internal/tools/valkey"
"github.com/spf13/cobra"

View File

@@ -190,6 +190,18 @@
"!sudo lsof -i :5432"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Optional: Enable Vertex AI API for Google Cloud\n",
"\n",
"If you're using a model hosted on **Vertex AI**, run the following command to enable the API:\n",
"\n",
"```bash\n",
"!gcloud services enable aiplatform.googleapis.com\n"
]
},
{
"cell_type": "markdown",
"metadata": {

View File

@@ -316,4 +316,273 @@ const tools = toolboxTools.map(getTool);
{{< /tabpane >}}
For more detailed instructions on using the Toolbox Core SDK, see the
[project's README](https://github.com/googleapis/mcp-toolbox-sdk-js/blob/main/packages/toolbox-core/README.md).
[project's README](https://github.com/googleapis/mcp-toolbox-sdk-js/blob/main/packages/toolbox-core/README.md).
#### Go
Once you've installed the [Toolbox Go
SDK](https://pkg.go.dev/github.com/googleapis/mcp-toolbox-sdk-go/core), you can load
tools:
{{< tabpane text=true persist=header >}}
{{% tab header="Core" lang="en" %}}
{{< highlight go >}}
package main
import (
"context"
"log"
"github.com/googleapis/mcp-toolbox-sdk-go/core"
)
func main() {
// update the url to point to your server
URL := "http://127.0.0.1:5000"
ctx := context.Background()
client, err := core.NewToolboxClient(URL)
if err != nil {
log.Fatalf("Failed to create Toolbox client: %v", err)
}
// Framework agnostic tools
tools, err := client.LoadToolset("toolsetName", ctx)
if err != nil {
log.Fatalf("Failed to load tools: %v", err)
}
}
{{< /highlight >}}
{{% /tab %}}
{{% tab header="LangChain Go" lang="en" %}}
{{< highlight go >}}
package main
import (
"context"
"encoding/json"
"log"
"github.com/googleapis/mcp-toolbox-sdk-go/core"
"github.com/tmc/langchaingo/llms"
)
func main() {
// Make sure to add the error checks
// update the url to point to your server
URL := "http://127.0.0.1:5000"
ctx := context.Background()
client, err := core.NewToolboxClient(URL)
if err != nil {
log.Fatalf("Failed to create Toolbox client: %v", err)
}
// Framework agnostic tool
tool, err := client.LoadTool("toolName", ctx)
if err != nil {
log.Fatalf("Failed to load tools: %v", err)
}
// Fetch the tool's input schema
inputschema, err := tool.InputSchema()
if err != nil {
log.Fatalf("Failed to fetch inputSchema: %v", err)
}
var paramsSchema map[string]any
_ = json.Unmarshal(inputschema, &paramsSchema)
// Use this tool with LangChainGo
langChainTool := llms.Tool{
Type: "function",
Function: &llms.FunctionDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: paramsSchema,
},
}
}
{{< /highlight >}}
{{% /tab %}}
{{% tab header="Genkit Go" lang="en" %}}
{{< highlight go >}}
package main
import (
"context"
"encoding/json"
"log"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/googleapis/mcp-toolbox-sdk-go/core"
"github.com/invopop/jsonschema"
)
func main() {
// Make sure to add the error checks
// Update the url to point to your server
URL := "http://127.0.0.1:5000"
ctx := context.Background()
g, err := genkit.Init(ctx)
client, err := core.NewToolboxClient(URL)
if err != nil {
log.Fatalf("Failed to create Toolbox client: %v", err)
}
// Framework agnostic tool
tool, err := client.LoadTool("toolName", ctx)
if err != nil {
log.Fatalf("Failed to load tools: %v", err)
}
// Fetch the tool's input schema
inputschema, err := tool.InputSchema()
if err != nil {
log.Fatalf("Failed to fetch inputSchema: %v", err)
}
var schema *jsonschema.Schema
_ = json.Unmarshal(inputschema, &schema)
executeFn := func(ctx *ai.ToolContext, input any) (string, error) {
result, err := tool.Invoke(ctx, input.(map[string]any))
if err != nil {
// Propagate errors from the tool invocation.
return "", err
}
return result.(string), nil
}
// Use this tool with Genkit Go
genkitTool := genkit.DefineToolWithInputSchema(
g,
tool.Name(),
tool.Description(),
schema,
executeFn,
)
}
{{< /highlight >}}
{{% /tab %}}
{{% tab header="Go GenAI" lang="en" %}}
{{< highlight go >}}
package main
import (
"context"
"encoding/json"
"log"
"github.com/googleapis/mcp-toolbox-sdk-go/core"
"google.golang.org/genai"
)
func main() {
// Make sure to add the error checks
// Update the url to point to your server
URL := "http://127.0.0.1:5000"
ctx := context.Background()
client, err := core.NewToolboxClient(URL)
if err != nil {
log.Fatalf("Failed to create Toolbox client: %v", err)
}
// Framework agnostic tool
tool, err := client.LoadTool("toolName", ctx)
if err != nil {
log.Fatalf("Failed to load tools: %v", err)
}
// Fetch the tool's input schema
inputschema, err := tool.InputSchema()
if err != nil {
log.Fatalf("Failed to fetch inputSchema: %v", err)
}
var schema *genai.Schema
_ = json.Unmarshal(inputschema, &schema)
funcDeclaration := &genai.FunctionDeclaration{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schema,
}
// Use this tool with Go GenAI
genAITool := &genai.Tool{
FunctionDeclarations: []*genai.FunctionDeclaration{funcDeclaration},
}
}
{{< /highlight >}}
{{% /tab %}}
{{% tab header="OpenAI Go" lang="en" %}}
{{< highlight go >}}
package main
import (
"context"
"encoding/json"
"log"
"github.com/googleapis/mcp-toolbox-sdk-go/core"
openai "github.com/openai/openai-go"
)
func main() {
// Make sure to add the error checks
// Update the url to point to your server
URL := "http://127.0.0.1:5000"
ctx := context.Background()
client, err := core.NewToolboxClient(URL)
if err != nil {
log.Fatalf("Failed to create Toolbox client: %v", err)
}
// Framework agnostic tool
tool, err := client.LoadTool("toolName", ctx)
if err != nil {
log.Fatalf("Failed to load tools: %v", err)
}
// Fetch the tool's input schema
inputschema, err := tool.InputSchema()
if err != nil {
log.Fatalf("Failed to fetch inputSchema: %v", err)
}
var paramsSchema openai.FunctionParameters
_ = json.Unmarshal(inputschema, &paramsSchema)
// Use this tool with OpenAI Go
openAITool := openai.ChatCompletionToolParam{
Function: openai.FunctionDefinitionParam{
Name: tool.Name(),
Description: openai.String(tool.Description()),
Parameters: paramsSchema,
},
}
}
{{< /highlight >}}
{{% /tab %}}
{{< /tabpane >}}
For more detailed instructions on using the Toolbox Go SDK, see the
[project's README](https://github.com/googleapis/mcp-toolbox-sdk-go/blob/main/core/README.md).
For end-to-end samples on using the Toolbox Go SDK with orchestration frameworks, see the [project's samples](https://github.com/googleapis/mcp-toolbox-sdk-go/tree/main/core/samples)

View File

@@ -1,9 +1,9 @@
---
title: "Quickstart (Local)"
title: "Python Quickstart (Local)"
type: docs
weight: 2
description: >
How to get started running Toolbox locally with Python, PostgreSQL, and [Agent Development Kit](https://google.github.io/adk-docs/),
How to get started running Toolbox locally with [Python](https://github.com/googleapis/mcp-toolbox-sdk-python), PostgreSQL, and [Agent Development Kit](https://google.github.io/adk-docs/),
[LangGraph](https://www.langchain.com/langgraph), [LlamaIndex](https://www.llamaindex.ai/) or [GoogleGenAI](https://pypi.org/project/google-genai/).
---
@@ -14,8 +14,21 @@ description: >
This guide assumes you have already done the following:
1. Installed [Python 3.9+][install-python] (including [pip][install-pip] and
your preferred virtual environment tool for managing dependencies e.g. [venv][install-venv])
1. Installed [PostgreSQL 16+ and the `psql` client][install-postgres]
your preferred virtual environment tool for managing dependencies e.g. [venv][install-venv]).
1. Installed [PostgreSQL 16+ and the `psql` client][install-postgres].
### Cloud Setup (Optional)
If you plan to use **Google Clouds Vertex AI** with your agent (e.g., using `vertexai=True` or a Google GenAI model), follow these one-time setup steps for local development:
1. [Install the Google Cloud CLI](https://cloud.google.com/sdk/docs/install)
1. [Set up Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment)
1. Set your project and enable Vertex AI
```bash
gcloud config set project YOUR_PROJECT_ID
gcloud services enable aiplatform.googleapis.com
```
[install-python]: https://wiki.python.org/moin/BeginnersGuide/Download
[install-pip]: https://pip.pypa.io/en/stable/installation/
@@ -141,6 +154,7 @@ postgres` and a password next time.
\q
```
## Step 2: Install and configure Toolbox
In this section, we will download Toolbox, configure our tools in a
@@ -632,7 +646,7 @@ asyncio.run(run_application())
{{< /tab >}}
{{< /tabpane >}}
{{< tabpane text=true persist=header >}}
{{< tabpane text=true persist=header >}}
{{% tab header="ADK" lang="en" %}}
To learn more about Agent Development Kit, check out the [ADK
documentation.](https://google.github.io/adk-docs/)
@@ -658,3 +672,7 @@ Documentation](https://github.com/googleapis/python-genai?tab=readme-ov-file#man
```sh
python hotel_agent.py
```
{{< notice info >}}
For more information, visit the [Python SDK repo](https://github.com/googleapis/mcp-toolbox-sdk-python).
{{</ notice >}}

View File

@@ -0,0 +1,490 @@
---
title: "JS Quickstart (Local)"
type: docs
weight: 3
description: >
How to get started running Toolbox locally with [JavaScript](https://github.com/googleapis/mcp-toolbox-sdk-python), PostgreSQL, and orchestration frameworks such as [LangChain](https://js.langchain.com/docs/introduction/) and [GenkitJS](https://genkit.dev/docs/get-started/).
---
## Before you begin
This guide assumes you have already done the following:
1. Installed [Node.js (v18 or higher)].
1. Installed [PostgreSQL 16+ and the `psql` client][install-postgres].
### Cloud Setup (Optional)
If you plan to use **Google Clouds Vertex AI** with your agent (e.g., using Gemini or PaLM models), follow these one-time setup steps:
1. [Install the Google Cloud CLI]
1. [Set up Application Default Credentials (ADC)]
1. Set your project and enable Vertex AI
```bash
gcloud config set project YOUR_PROJECT_ID
gcloud services enable aiplatform.googleapis.com
```
[Node.js (v18 or higher)]: https://nodejs.org/
[install-postgres]: https://www.postgresql.org/download/
[Install the Google Cloud CLI]: https://cloud.google.com/sdk/docs/install
[Set up Application Default Credentials (ADC)]: https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment
## Step 1: Set up your database
In this section, we will create a database, insert some data that needs to be
accessed by our agent, and create a database user for Toolbox to connect with.
1. Connect to postgres using the `psql` command:
```bash
psql -h 127.0.0.1 -U postgres
```
Here, `postgres` denotes the default postgres superuser.
{{< notice info >}}
#### **Having trouble connecting?**
* **Password Prompt:** If you are prompted for a password for the `postgres`
user and do not know it (or a blank password doesn't work), your PostgreSQL
installation might require a password or a different authentication method.
* **`FATAL: role "postgres" does not exist`:** This error means the default
`postgres` superuser role isn't available under that name on your system.
* **`Connection refused`:** Ensure your PostgreSQL server is actually running.
You can typically check with `sudo systemctl status postgresql` and start it
with `sudo systemctl start postgresql` on Linux systems.
<br/>
#### **Common Solution**
For password issues or if the `postgres` role seems inaccessible directly, try
switching to the `postgres` operating system user first. This user often has
permission to connect without a password for local connections (this is called
peer authentication).
```bash
sudo -i -u postgres
psql -h 127.0.0.1
```
Once you are in the `psql` shell using this method, you can proceed with the
database creation steps below. Afterwards, type `\q` to exit `psql`, and then
`exit` to return to your normal user shell.
If desired, once connected to `psql` as the `postgres` OS user, you can set a
password for the `postgres` *database* user using: `ALTER USER postgres WITH
PASSWORD 'your_chosen_password';`. This would allow direct connection with `-U
postgres` and a password next time.
{{< /notice >}}
1. Create a new database and a new user:
{{< notice tip >}}
For a real application, it's best to follow the principle of least permission
and only grant the privileges your application needs.
{{< /notice >}}
```sql
CREATE USER toolbox_user WITH PASSWORD 'my-password';
CREATE DATABASE toolbox_db;
GRANT ALL PRIVILEGES ON DATABASE toolbox_db TO toolbox_user;
ALTER DATABASE toolbox_db OWNER TO toolbox_user;
```
1. End the database session:
```bash
\q
```
(If you used `sudo -i -u postgres` and then `psql`, remember you might also
need to type `exit` after `\q` to leave the `postgres` user's shell
session.)
1. Connect to your database with your new user:
```bash
psql -h 127.0.0.1 -U toolbox_user -d toolbox_db
```
1. Create a table using the following command:
```sql
CREATE TABLE hotels(
id INTEGER NOT NULL PRIMARY KEY,
name VARCHAR NOT NULL,
location VARCHAR NOT NULL,
price_tier VARCHAR NOT NULL,
checkin_date DATE NOT NULL,
checkout_date DATE NOT NULL,
booked BIT NOT NULL
);
```
1. Insert data into the table.
```sql
INSERT INTO hotels(id, name, location, price_tier, checkin_date, checkout_date, booked)
VALUES
(1, 'Hilton Basel', 'Basel', 'Luxury', '2024-04-22', '2024-04-20', B'0'),
(2, 'Marriott Zurich', 'Zurich', 'Upscale', '2024-04-14', '2024-04-21', B'0'),
(3, 'Hyatt Regency Basel', 'Basel', 'Upper Upscale', '2024-04-02', '2024-04-20', B'0'),
(4, 'Radisson Blu Lucerne', 'Lucerne', 'Midscale', '2024-04-24', '2024-04-05', B'0'),
(5, 'Best Western Bern', 'Bern', 'Upper Midscale', '2024-04-23', '2024-04-01', B'0'),
(6, 'InterContinental Geneva', 'Geneva', 'Luxury', '2024-04-23', '2024-04-28', B'0'),
(7, 'Sheraton Zurich', 'Zurich', 'Upper Upscale', '2024-04-27', '2024-04-02', B'0'),
(8, 'Holiday Inn Basel', 'Basel', 'Upper Midscale', '2024-04-24', '2024-04-09', B'0'),
(9, 'Courtyard Zurich', 'Zurich', 'Upscale', '2024-04-03', '2024-04-13', B'0'),
(10, 'Comfort Inn Bern', 'Bern', 'Midscale', '2024-04-04', '2024-04-16', B'0');
```
1. End the database session:
```bash
\q
```
## Step 2: Install and configure Toolbox
In this section, we will download Toolbox, configure our tools in a
`tools.yaml`, and then run the Toolbox server.
1. Download the latest version of Toolbox as a binary:
{{< notice tip >}}
Select the
[correct binary](https://github.com/googleapis/genai-toolbox/releases)
corresponding to your OS and CPU architecture.
{{< /notice >}}
<!-- {x-release-please-start-version} -->
```bash
export OS="linux/amd64" # one of linux/amd64, darwin/arm64, darwin/amd64, or windows/amd64
curl -O https://storage.googleapis.com/genai-toolbox/v0.9.0/$OS/toolbox
```
<!-- {x-release-please-end} -->
1. Make the binary executable:
```bash
chmod +x toolbox
```
1. Write the following into a `tools.yaml` file. Be sure to update any fields
such as `user`, `password`, or `database` that you may have customized in the
previous step.
{{< notice tip >}}
In practice, use environment variable replacement with the format ${ENV_NAME}
instead of hardcoding your secrets into the configuration file.
{{< /notice >}}
```yaml
sources:
my-pg-source:
kind: postgres
host: 127.0.0.1
port: 5432
database: toolbox_db
user: ${USER_NAME}
password: ${PASSWORD}
tools:
search-hotels-by-name:
kind: postgres-sql
source: my-pg-source
description: Search for hotels based on name.
parameters:
- name: name
type: string
description: The name of the hotel.
statement: SELECT * FROM hotels WHERE name ILIKE '%' || $1 || '%';
search-hotels-by-location:
kind: postgres-sql
source: my-pg-source
description: Search for hotels based on location.
parameters:
- name: location
type: string
description: The location of the hotel.
statement: SELECT * FROM hotels WHERE location ILIKE '%' || $1 || '%';
book-hotel:
kind: postgres-sql
source: my-pg-source
description: >-
Book a hotel by its ID. If the hotel is successfully booked, returns a NULL, raises an error if not.
parameters:
- name: hotel_id
type: string
description: The ID of the hotel to book.
statement: UPDATE hotels SET booked = B'1' WHERE id = $1;
update-hotel:
kind: postgres-sql
source: my-pg-source
description: >-
Update a hotel's check-in and check-out dates by its ID. Returns a message
indicating whether the hotel was successfully updated or not.
parameters:
- name: hotel_id
type: string
description: The ID of the hotel to update.
- name: checkin_date
type: string
description: The new check-in date of the hotel.
- name: checkout_date
type: string
description: The new check-out date of the hotel.
statement: >-
UPDATE hotels SET checkin_date = CAST($2 as date), checkout_date = CAST($3
as date) WHERE id = $1;
cancel-hotel:
kind: postgres-sql
source: my-pg-source
description: Cancel a hotel by its ID.
parameters:
- name: hotel_id
type: string
description: The ID of the hotel to cancel.
statement: UPDATE hotels SET booked = B'0' WHERE id = $1;
toolsets:
my-toolset:
- search-hotels-by-name
- search-hotels-by-location
- book-hotel
- update-hotel
- cancel-hotel
```
For more info on tools, check out the `Resources` section of the docs.
1. Run the Toolbox server, pointing to the `tools.yaml` file created earlier:
```bash
./toolbox --tools-file "tools.yaml"
```
{{< notice note >}}
Toolbox enables dynamic reloading by default. To disable, use the `--disable-reload` flag.
{{< /notice >}}
## Step 3: Connect your agent to Toolbox
In this section, we will write and run an agent that will load the Tools
from Toolbox.
1. (Optional) Initialize a Node.js project:
```bash
npm init -y
```
1. In a new terminal, install the [SDK](https://www.npmjs.com/package/@toolbox-sdk/core).
```bash
npm install langchain @toolbox-sdk/core
```
1. Install other required dependencies
{{< tabpane persist=header >}}
{{< tab header="LangChain" lang="bash" >}}
npm install langchain @langchain/google-vertexai
{{< /tab >}}
{{< tab header="GenkitJS" lang="bash" >}}
npm install genkit @genkit-ai/vertexai
{{< /tab >}}
{{< /tabpane >}}
1. Create a new file named `hotelAgent.js` and copy the following code to create an agent:
{{< tabpane persist=header >}}
{{< tab header="LangChain" lang="js" >}}
import { ChatVertexAI } from "@langchain/google-vertexai";
import { ToolboxClient } from "@toolbox-sdk/core";
import { tool } from "@langchain/core/tools";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { MemorySaver } from "@langchain/langgraph";
// Replace it with your API key
process.env.GOOGLE_API_KEY = 'your-api-key';
const prompt = `
You're a helpful hotel assistant. You handle hotel searching, booking, and
cancellations. When the user searches for a hotel, mention its name, id,
location and price tier. Always mention hotel ids while performing any
searches. This is very important for any operations. For any bookings or
cancellations, please provide the appropriate confirmation. Be sure to
update checkin or checkout dates if mentioned by the user.
Don't ask for confirmations from the user.
`;
const queries = [
"Find hotels in Basel with Basel in its name.",
"Can you book the Hilton Basel for me?",
"Oh wait, this is too expensive. Please cancel it and book the Hyatt Regency instead.",
"My check in dates would be from April 10, 2024 to April 19, 2024.",
];
async function runApplication() {
const model = new ChatVertexAI({
model: "gemini-2.0-flash",
});
const client = new ToolboxClient("http://127.0.0.1:5000");
const toolboxTools = await client.loadToolset("my-toolset");
// Define the basics of the tool: name, description, schema and core logic
const getTool = (toolboxTool) => tool(toolboxTool, {
name: toolboxTool.getName(),
description: toolboxTool.getDescription(),
schema: toolboxTool.getParamSchema()
});
const tools = toolboxTools.map(getTool);
const agent = createReactAgent({
llm: model,
tools: tools,
checkpointer: new MemorySaver(),
systemPrompt: prompt,
});
const langGraphConfig = {
configurable: {
thread_id: "test-thread",
},
};
for (const query of queries) {
const agentOutput = await agent.invoke(
{
messages: [
{
role: "user",
content: query,
},
],
verbose: true,
},
langGraphConfig
);
const response = agentOutput.messages[agentOutput.messages.length - 1].content;
console.log(response);
}
}
runApplication()
.catch(console.error)
.finally(() => console.log("\nApplication finished."));
{{< /tab >}}
{{< tab header="GenkitJS" lang="js" >}}
import { ToolboxClient } from "@toolbox-sdk/core";
import { genkit } from "genkit";
import { googleAI } from '@genkit-ai/googleai';
// Replace it with your API key
process.env.GOOGLE_API_KEY = 'your-api-key';
const systemPrompt = `
You're a helpful hotel assistant. You handle hotel searching, booking, and
cancellations. When the user searches for a hotel, mention its name, id,
location and price tier. Always mention hotel ids while performing any
searches. This is very important for any operations. For any bookings or
cancellations, please provide the appropriate confirmation. Be sure to
update checkin or checkout dates if mentioned by the user.
Don't ask for confirmations from the user.
`;
const queries = [
"Find hotels in Basel with Basel in its name.",
"Can you book the Hilton Basel for me?",
"Oh wait, this is too expensive. Please cancel it and book the Hyatt Regency instead.",
"My check in dates would be from April 10, 2024 to April 19, 2024.",
];
async function run() {
const toolboxClient = new ToolboxClient("http://127.0.0.1:5000");
const ai = genkit({
plugins: [
googleAI({
apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
})
],
model: googleAI.model('gemini-2.0-flash'),
});
const toolboxTools = await toolboxClient.loadToolset("my-toolset");
const toolMap = Object.fromEntries(
toolboxTools.map((tool) => {
const definedTool = ai.defineTool(
{
name: tool.getName(),
description: tool.getDescription(),
inputSchema: tool.getParamSchema(),
},
tool
);
return [tool.getName(), definedTool];
})
);
const tools = Object.values(toolMap);
let conversationHistory = [{ role: "system", content: [{ text: systemPrompt }] }];
for (const query of queries) {
conversationHistory.push({ role: "user", content: [{ text: query }] });
const response = await ai.generate({
messages: conversationHistory,
tools: tools,
});
conversationHistory.push(response.message);
const toolRequests = response.toolRequests;
if (toolRequests?.length > 0) {
// Execute tools concurrently and collect their responses.
const toolResponses = await Promise.all(
toolRequests.map(async (call) => {
try {
const toolOutput = await toolMap[call.name].invoke(call.input);
return { role: "tool", content: [{ toolResponse: { name: call.name, output: toolOutput } }] };
} catch (e) {
console.error(`Error executing tool ${call.name}:`, e);
return { role: "tool", content: [{ toolResponse: { name: call.name, output: { error: e.message } } }] };
}
})
);
conversationHistory.push(...toolResponses);
// Call the AI again with the tool results.
response = await ai.generate({ messages: conversationHistory, tools });
conversationHistory.push(response.message);
}
console.log(response.text);
}
}
run();
{{< /tab >}}
{{< /tabpane >}}
1. Run your agent, and observe the results:
```sh
node hotelAgent.js
```
{{< notice info >}}
For more information, visit the [JS SDK repo](https://github.com/googleapis/mcp-toolbox-sdk-js).
{{</ notice >}}

View File

@@ -218,10 +218,16 @@ In this section, we will download Toolbox, configure our tools in a
1. Type `y` when it asks to install the inspector package.
1. It should show the following when the MCP Inspector is up and running:
1. It should show the following when the MCP Inspector is up and running (please take note of <YOUR_SESSION_TOKEN>):
```bash
🔍 MCP Inspector is up and running at http://127.0.0.1:5173 🚀
Starting MCP inspector...
⚙️ Proxy server listening on localhost:6277
🔑 Session token: <YOUR_SESSION_TOKEN>
Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth
🚀 MCP Inspector is up and running at:
http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=<YOUR_SESSION_TOKEN>
```
1. Open the above link in your browser.
@@ -230,6 +236,8 @@ In this section, we will download Toolbox, configure our tools in a
1. For `URL`, type in `http://127.0.0.1:5000/mcp/sse`.
1. For `Authentication` -> `Proxy Session Token`, make sure <YOUR_SESSION_TOKEN> is present.
1. Click Connect.
![inspector](./inspector.png)

View File

@@ -20,18 +20,16 @@ The native SDKs can be combined with MCP clients in many cases.
Toolbox currently supports the following versions of MCP specification:
* [2024-11-05](https://spec.modelcontextprotocol.io/specification/2024-11-05/)
* [2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18)
* [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26)
* [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05)
### Features Not Supported by MCP
### Toolbox AuthZ/AuthN Not Supported by MCP
Toolbox has several features that are not yet supported in the MCP specification:
* **AuthZ/AuthN:** There are no auth implementation in the `2024-11-05`
specification. This includes:
The auth implementation in Toolbox is not supported in MCP's auth specification.
This includes:
* [Authenticated Parameters](../resources/tools/_index.md#authenticated-parameters)
* [Authorized Invocations](../resources/tools/_index.md#authorized-invocations)
* **Notifications:** Currently, editing Toolbox Tools requires a server restart.
Clients should reload tools on disconnect to get the latest version.
## Connecting to Toolbox with an MCP client
@@ -87,11 +85,25 @@ Add the following configuration to your MCP client configuration:
If you would like to connect to a specific toolset, replace `url` with
`"http://127.0.0.1:5000/mcp/{toolset_name}/sse"`.
{{% /tab %}} {{% tab header="HTTP POST" lang="en" %}}
Connect to Toolbox HTTP POST via `http://127.0.0.1:5000/mcp`.
If you would like to connect to a specific toolset, connect via
`http://127.0.0.1:5000/mcp/{toolset_name}`.
HTTP with SSE is only supported in version `2024-11-05` and is currently
deprecated.
{{% /tab %}} {{% tab header="Streamable HTTP" lang="en" %}}
Add the following configuration to your MCP client configuration:
```bash
{
"mcpServers": {
"toolbox": {
"type": "http",
"url": "http://127.0.0.1:5000/mcp",
}
}
}
```
If you would like to connect to a specific toolset, replace `url` with
`"http://127.0.0.1:5000/mcp/{toolset_name}"`.
{{% /tab %}} {{< /tabpane >}}
### Using the MCP Inspector with Toolbox
@@ -132,6 +144,23 @@ testing and debugging Toolbox server.
1. For `URL`, type in `http://127.0.0.1:5000/mcp/sse` to use all tool or
`http//127.0.0.1:5000/mcp/{toolset_name}/sse` to use a specific toolset.
1. Click the `Connect` button. Voila! You should be able to inspect your toolbox
tools!
{{% /tab %}}
{{% tab header="Streamable HTTP" lang="en" %}}
1. [Run Toolbox](../getting-started/introduction/_index.md#running-the-server).
1. In a separate terminal, run Inspector directly through `npx`:
```bash
npx @modelcontextprotocol/inspector
```
1. For `Transport Type` dropdown menu, select `Streamable HTTP`.
1. For `URL`, type in `http://127.0.0.1:5000/mcp` to use all tool or
`http//127.0.0.1:5000/mcp/{toolset_name}` to use a specific toolset.
1. Click the `Connect` button. Voila! You should be able to inspect your toolbox
tools!
{{% /tab %}} {{< /tabpane >}}

View File

@@ -20,8 +20,11 @@ instance. It's compatible with any of the following sources:
Bigtable supports SQL queries. The integration with Toolbox supports `googlesql`
dialect, the specified SQL statement is executed as a [data manipulation
language (DML)][bigtable-googlesql] statements, and specified parameters will
inserted according to their name: e.g. `@name`.
language (DML)][bigtable-googlesql] statements, and specified parameters will inserted according to their name: e.g. `@name`.
{{<notice note>}}
Bigtable's GoogleSQL support for DML statements might be limited to certain query types. For detailed information on supported DML statements and use cases, refer to the [Bigtable GoogleSQL use cases](https://cloud.google.com/bigtable/docs/googlesql-overview#use-cases).
{{</notice>}}
[bigtable-googlesql]: https://cloud.google.com/bigtable/docs/googlesql-overview

View File

@@ -0,0 +1,22 @@
# update-mcp-settings
## Description
The `update-mcp-settings` tool is a utility that updates the MCP (Model Context Protocol) settings file with the necessary environment variables for a given tool. This is particularly useful when you need to configure a tool with specific environment variables being set previously in chat for AlloyDB Control Plane.
## Configuration
To use the `update-mcp-settings` tool, you need to configure it in your `toolbox.yaml` file. Here is an example configuration:
```yaml
tools:
update-mcp-settings-tool:
kind: update-mcp-settings
description: "Run this tool to update mcp json file prebuilt tool for data plane with right parameters ALLOYDB_POSTGRES_PROJECT, ALLOYDB_POSTGRES_REGION, ALLOYDB_POSTGRES_CLUSTER, ALLOYDB_POSTGRES_INSTANCE, ALLOYDB_POSTGRES_DATABASE, ALLOYDB_POSTGRES_USER, ALLOYDB_POSTGRES_PASSWORD. Identify the mcp settings json file or ask user to share it's full path. Run this tool once cluster and instance creation is done."
```
## Reference
| **field** | **type** | **required** | **description** |
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
| kind | string | true | Must be "update-mcp-settings". |
| description | string | true | Description of the tool that is passed to the LLM. |

View File

@@ -0,0 +1,37 @@
---
title: "wait"
type: docs
weight: 1
description: >
A "wait" tool pauses execution for a specified duration.
aliases:
- /resources/tools/utility/wait
---
## About
A `wait` tool pauses execution for a specified duration. This can be useful in workflows where a delay is needed between steps.
`wait` takes one input parameter `duration` which is a string representing the time to wait (e.g., "10s", "2m", "1h").
{{% notice info %}}
This tool is intended for developer assistant workflows with human-in-the-loop and shouldn't be used for production agents.
{{% /notice %}}
## Example
```yaml
tools:
wait_for_tool:
kind: wait
description: Use this tool to pause execution for a specified duration.
timeout: 30s
```
## Reference
| **field** | **type** | **required** | **description** |
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
| kind | string | true | Must be "wait". |
| description | string | true | Description of the tool that is passed to the LLM. |
| timeout | string | true | The default duration the tool can wait for. |

View File

@@ -6,3 +6,10 @@ icon: fa-brands fa-golang
manualLink: "https://github.com/googleapis/mcp-toolbox-sdk-go"
manualLinkTarget: _blank
---
<html>
<head>
<link rel="canonical" href="https://github.com/googleapis/mcp-toolbox-sdk-go"/>
<meta http-equiv="refresh" content="0;url=https://github.com/googleapis/mcp-toolbox-sdk-go"/>
</head>
</html>

View File

@@ -5,4 +5,11 @@ description: Javascript client SDK
icon: fa-brands fa-node-js
manualLink: "https://github.com/googleapis/mcp-toolbox-sdk-js"
manualLinkTarget: _blank
---
---
<html>
<head>
<link rel="canonical" href="https://github.com/googleapis/mcp-toolbox-sdk-js"/>
<meta http-equiv="refresh" content="0;url=https://github.com/googleapis/mcp-toolbox-sdk-js"/>
</head>
</html>

View File

@@ -5,4 +5,11 @@ description: Python client SDK
icon: fa-brands fa-python
manualLink: "https://github.com/googleapis/mcp-toolbox-sdk-python"
manualLinkTarget: _blank
---
---
<html>
<head>
<link rel="canonical" href="https://github.com/googleapis/mcp-toolbox-sdk-python"/>
<meta http-equiv="refresh" content="0;url=hhttps://github.com/googleapis/mcp-toolbox-sdk-python"/>
</head>
</html>

View File

@@ -133,6 +133,7 @@ func toolGetHandler(s *Server, w http.ResponseWriter, r *http.Request) {
func toolInvokeHandler(s *Server, w http.ResponseWriter, r *http.Request) {
ctx, span := s.instrumentation.Tracer.Start(r.Context(), "toolbox/server/tool/invoke")
r = r.WithContext(ctx)
ctx = util.WithLogger(r.Context(), s.logger)
toolName := chi.URLParam(r, "toolName")
s.logger.DebugContext(ctx, fmt.Sprintf("tool name: %s", toolName))

View File

@@ -357,6 +357,17 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
protocolVersion = v20250326.PROTOCOL_VERSION
}
// check if client have `MCP-Protocol-Version` header
headerProtocolVersion := r.Header.Get("MCP-Protocol-Version")
if headerProtocolVersion != "" {
if !mcp.VerifyProtocolVersion(headerProtocolVersion) {
err := fmt.Errorf("invalid protocol version: %s", headerProtocolVersion)
_ = render.Render(w, r, newErrResponse(err, http.StatusBadRequest))
return
}
protocolVersion = headerProtocolVersion
}
toolsetName := chi.URLParam(r, "toolsetName")
s.logger.DebugContext(ctx, fmt.Sprintf("toolset name: %s", toolsetName))
span.SetAttributes(attribute.String("toolset_name", toolsetName))
@@ -387,6 +398,7 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
s.logger.DebugContext(ctx, err.Error())
render.JSON(w, r, jsonrpc.NewError(id, jsonrpc.PARSE_ERROR, err.Error(), nil))
return
}
v, res, err := processMcpMessage(ctx, body, s, protocolVersion, toolsetName)
@@ -431,10 +443,6 @@ func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVers
return "", jsonrpc.NewError("", jsonrpc.INTERNAL_ERROR, err.Error(), nil), err
}
if protocolVersion == "" {
protocolVersion = v20241105.PROTOCOL_VERSION
}
// Generic baseMessage could either be a JSONRPCNotification or JSONRPCRequest
var baseMessage jsonrpc.BaseMessage
if err = util.DecodeJSON(bytes.NewBuffer(body), &baseMessage); err != nil {

View File

@@ -24,15 +24,20 @@ import (
mcputil "github.com/googleapis/genai-toolbox/internal/server/mcp/util"
v20241105 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20241105"
v20250326 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20250326"
v20250618 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20250618"
"github.com/googleapis/genai-toolbox/internal/tools"
)
// LATEST_PROTOCOL_VERSION is the latest version of the MCP protocol supported.
// Update the version used in InitializeResponse when this value is updated.
const LATEST_PROTOCOL_VERSION = v20250326.PROTOCOL_VERSION
const LATEST_PROTOCOL_VERSION = v20250618.PROTOCOL_VERSION
// SUPPORTED_PROTOCOL_VERSIONS is the MCP protocol versions that are supported.
var SUPPORTED_PROTOCOL_VERSIONS = []string{v20241105.PROTOCOL_VERSION, v20250326.PROTOCOL_VERSION}
var SUPPORTED_PROTOCOL_VERSIONS = []string{
v20241105.PROTOCOL_VERSION,
v20250326.PROTOCOL_VERSION,
v20250618.PROTOCOL_VERSION,
}
// InitializeResponse runs capability negotiation and protocol version agreement.
// This is the Initialization phase of the lifecycle for MCP client-server connections.
@@ -61,7 +66,9 @@ func InitializeResponse(ctx context.Context, id jsonrpc.RequestId, body []byte,
},
},
ServerInfo: mcputil.Implementation{
Name: mcputil.SERVER_NAME,
BaseMetadata: mcputil.BaseMetadata{
Name: mcputil.SERVER_NAME,
},
Version: toolboxVersion,
},
}
@@ -88,12 +95,16 @@ func NotificationHandler(ctx context.Context, body []byte) error {
// This is the Operation phase of the lifecycle for MCP client-server connections.
func ProcessMethod(ctx context.Context, mcpVersion string, id jsonrpc.RequestId, method string, toolset tools.Toolset, tools map[string]tools.Tool, body []byte) (any, error) {
switch mcpVersion {
case v20250618.PROTOCOL_VERSION:
return v20250618.ProcessMethod(ctx, id, method, toolset, tools, body)
case v20250326.PROTOCOL_VERSION:
return v20250326.ProcessMethod(ctx, id, method, toolset, tools, body)
case v20241105.PROTOCOL_VERSION:
return v20241105.ProcessMethod(ctx, id, method, toolset, tools, body)
default:
err := fmt.Errorf("invalid protocol version: %s", mcpVersion)
return jsonrpc.NewError(id, jsonrpc.INVALID_REQUEST, err.Error(), nil), err
return v20241105.ProcessMethod(ctx, id, method, toolset, tools, body)
}
}
// VerifyProtocolVersion verifies if the version string is valid.
func VerifyProtocolVersion(version string) bool {
return slices.Contains(SUPPORTED_PROTOCOL_VERSIONS, version)
}

View File

@@ -89,8 +89,22 @@ type ServerCapabilities struct {
Tools *ListChanged `json:"tools,omitempty"`
}
// Base interface for metadata with name (identifier) and title (display name) properties.
type BaseMetadata struct {
// Intended for programmatic or logical use, but used as a display name in past specs
// or fallback (if title isn't present).
Name string `json:"name"`
// Intended for UI and end-user contexts — optimized to be human-readable and easily understood,
//even by those unfamiliar with domain-specific terminology.
//
// If not provided, the name should be used for display (except for Tool,
// where `annotations.title` should be given precedence over using `name`,
// if present).
Title string `json:"title,omitempty"`
}
// Implementation describes the name and version of an MCP implementation.
type Implementation struct {
Name string `json:"name"`
BaseMetadata
Version string `json:"version"`
}

View File

@@ -0,0 +1,141 @@
// 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 v20250618
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util"
)
// ProcessMethod returns a response for the request.
func ProcessMethod(ctx context.Context, id jsonrpc.RequestId, method string, toolset tools.Toolset, tools map[string]tools.Tool, body []byte) (any, error) {
switch method {
case TOOLS_LIST:
return toolsListHandler(id, toolset, body)
case TOOLS_CALL:
return toolsCallHandler(ctx, id, tools, body)
default:
err := fmt.Errorf("invalid method %s", method)
return jsonrpc.NewError(id, jsonrpc.METHOD_NOT_FOUND, err.Error(), nil), err
}
}
func toolsListHandler(id jsonrpc.RequestId, toolset tools.Toolset, body []byte) (any, error) {
var req ListToolsRequest
if err := json.Unmarshal(body, &req); err != nil {
err = fmt.Errorf("invalid mcp tools list request: %w", err)
return jsonrpc.NewError(id, jsonrpc.INVALID_REQUEST, err.Error(), nil), err
}
result := ListToolsResult{
Tools: toolset.McpManifest,
}
return jsonrpc.JSONRPCResponse{
Jsonrpc: jsonrpc.JSONRPC_VERSION,
Id: id,
Result: result,
}, nil
}
// toolsCallHandler generate a response for tools call.
func toolsCallHandler(ctx context.Context, id jsonrpc.RequestId, tools map[string]tools.Tool, body []byte) (any, error) {
// retrieve logger from context
logger, err := util.LoggerFromContext(ctx)
if err != nil {
return jsonrpc.NewError(id, jsonrpc.INTERNAL_ERROR, err.Error(), nil), err
}
var req CallToolRequest
if err = json.Unmarshal(body, &req); err != nil {
err = fmt.Errorf("invalid mcp tools call request: %w", err)
return jsonrpc.NewError(id, jsonrpc.INVALID_REQUEST, err.Error(), nil), err
}
toolName := req.Params.Name
toolArgument := req.Params.Arguments
logger.DebugContext(ctx, fmt.Sprintf("tool name: %s", toolName))
tool, ok := tools[toolName]
if !ok {
err = fmt.Errorf("invalid tool name: tool with name %q does not exist", toolName)
return jsonrpc.NewError(id, jsonrpc.INVALID_PARAMS, err.Error(), nil), err
}
// marshal arguments and decode it using decodeJSON instead to prevent loss between floats/int.
aMarshal, err := json.Marshal(toolArgument)
if err != nil {
err = fmt.Errorf("unable to marshal tools argument: %w", err)
return jsonrpc.NewError(id, jsonrpc.INTERNAL_ERROR, err.Error(), nil), err
}
var data map[string]any
if err = util.DecodeJSON(bytes.NewBuffer(aMarshal), &data); err != nil {
err = fmt.Errorf("unable to decode tools argument: %w", err)
return jsonrpc.NewError(id, jsonrpc.INTERNAL_ERROR, err.Error(), nil), err
}
// claimsFromAuth maps the name of the authservice to the claims retrieved from it.
// Since MCP doesn't support auth, an empty map will be use every time.
claimsFromAuth := make(map[string]map[string]any)
params, err := tool.ParseParams(data, claimsFromAuth)
if err != nil {
err = fmt.Errorf("provided parameters were invalid: %w", err)
return jsonrpc.NewError(id, jsonrpc.INVALID_PARAMS, err.Error(), nil), err
}
logger.DebugContext(ctx, fmt.Sprintf("invocation params: %s", params))
if !tool.Authorized([]string{}) {
err = fmt.Errorf("unauthorized Tool call: `authRequired` is set for the target Tool")
return jsonrpc.NewError(id, jsonrpc.INVALID_REQUEST, err.Error(), nil), err
}
// run tool invocation and generate response.
results, err := tool.Invoke(ctx, params)
if err != nil {
text := TextContent{
Type: "text",
Text: err.Error(),
}
return jsonrpc.JSONRPCResponse{
Jsonrpc: jsonrpc.JSONRPC_VERSION,
Id: id,
Result: CallToolResult{Content: []TextContent{text}, IsError: true},
}, nil
}
content := make([]TextContent, 0)
for _, d := range results {
text := TextContent{Type: "text"}
dM, err := json.Marshal(d)
if err != nil {
text.Text = fmt.Sprintf("fail to marshal: %s, result: %s", err, d)
} else {
text.Text = string(dM)
}
content = append(content, text)
}
return jsonrpc.JSONRPCResponse{
Jsonrpc: jsonrpc.JSONRPC_VERSION,
Id: id,
Result: CallToolResult{Content: content},
}, nil
}

View File

@@ -0,0 +1,180 @@
// 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 v20250618
import (
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
"github.com/googleapis/genai-toolbox/internal/tools"
)
// SERVER_NAME is the server name used in Implementation.
const SERVER_NAME = "Toolbox"
// PROTOCOL_VERSION is the version of the MCP protocol in this package.
const PROTOCOL_VERSION = "2025-06-18"
// methods that are supported.
const (
TOOLS_LIST = "tools/list"
TOOLS_CALL = "tools/call"
)
/* Empty result */
// EmptyResult represents a response that indicates success but carries no data.
type EmptyResult jsonrpc.Result
/* Pagination */
// Cursor is an opaque token used to represent a cursor for pagination.
type Cursor string
type PaginatedRequest struct {
jsonrpc.Request
Params struct {
// An opaque token representing the current pagination position.
// If provided, the server should return results starting after this cursor.
Cursor Cursor `json:"cursor,omitempty"`
} `json:"params,omitempty"`
}
type PaginatedResult struct {
jsonrpc.Result
// An opaque token representing the pagination position after the last returned result.
// If present, there may be more results available.
NextCursor Cursor `json:"nextCursor,omitempty"`
}
/* Tools */
// Sent from the client to request a list of tools the server has.
type ListToolsRequest struct {
PaginatedRequest
}
// The server's response to a tools/list request from the client.
type ListToolsResult struct {
PaginatedResult
Tools []tools.McpManifest `json:"tools"`
}
// Used by the client to invoke a tool provided by the server.
type CallToolRequest struct {
jsonrpc.Request
Params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
} `json:"params,omitempty"`
}
// The sender or recipient of messages and data in a conversation.
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
// Base for objects that include optional annotations for the client.
// The client can use annotations to inform how objects are used or displayed
type Annotated struct {
Annotations *struct {
// Describes who the intended customer of this object or data is.
// It can include multiple entries to indicate content useful for multiple
// audiences (e.g., `["user", "assistant"]`).
Audience []Role `json:"audience,omitempty"`
// Describes how important this data is for operating the server.
//
// A value of 1 means "most important," and indicates that the data is
// effectively required, while 0 means "least important," and indicates that
// the data is entirely optional.
//
// @TJS-type number
// @minimum 0
// @maximum 1
Priority float64 `json:"priority,omitempty"`
} `json:"annotations,omitempty"`
}
// TextContent represents text provided to or from an LLM.
type TextContent struct {
Annotated
Type string `json:"type"`
// The text content of the message.
Text string `json:"text"`
}
// The server's response to a tool call.
//
// Any errors that originate from the tool SHOULD be reported inside the result
// object, with `isError` set to true, _not_ as an MCP protocol-level error
// response. Otherwise, the LLM would not be able to see that an error occurred
// and self-correct.
//
// However, any errors in _finding_ the tool, an error indicating that the
// server does not support tool calls, or any other exceptional conditions,
// should be reported as an MCP error response.
type CallToolResult struct {
jsonrpc.Result
// Could be either a TextContent, ImageContent, or EmbeddedResources
// For Toolbox, we will only be sending TextContent
Content []TextContent `json:"content"`
// Whether the tool call ended in an error.
// If not set, this is assumed to be false (the call was successful).
//
// Any errors that originate from the tool SHOULD be reported inside the result
// object, with `isError` set to true, _not_ as an MCP protocol-level error
// response. Otherwise, the LLM would not be able to see that an error occurred
// and self-correct.
//
// However, any errors in _finding_ the tool, an error indicating that the
// server does not support tool calls, or any other exceptional conditions,
// should be reported as an MCP error response.
IsError bool `json:"isError,omitempty"`
// An optional JSON object that represents the structured result of the tool call.
StructuredContent map[string]any `json:"structuredContent,omitempty"`
}
// Additional properties describing a Tool to clients.
//
// NOTE: all properties in ToolAnnotations are **hints**.
// They are not guaranteed to provide a faithful description of
// tool behavior (including descriptive properties like `title`).
//
// Clients should never make tool use decisions based on ToolAnnotations
// received from untrusted servers.
type ToolAnnotations struct {
// A human-readable title for the tool.
Title string `json:"title,omitempty"`
// If true, the tool does not modify its environment.
// Default: false
ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
// If true, the tool may perform destructive updates to its environment.
// If false, the tool performs only additive updates.
// (This property is meaningful only when `readOnlyHint == false`)
// Default: true
DestructiveHint bool `json:"destructiveHint,omitempty"`
// If true, calling the tool repeatedly with the same arguments
// will have no additional effect on the its environment.
// (This property is meaningful only when `readOnlyHint == false`)
// Default: false
IdempotentHint bool `json:"idempotentHint,omitempty"`
// If true, this tool may interact with an "open world" of external
// entities. If false, the tool's domain of interaction is closed.
// For example, the world of a web search tool is open, whereas that
// of a memory tool is not.
// Default: true
OpenWorldHint bool `json:"openWorldHint,omitempty"`
}

View File

@@ -36,6 +36,7 @@ import (
const jsonrpcVersion = "2.0"
const protocolVersion20241105 = "2024-11-05"
const protocolVersion20250326 = "2025-03-26"
const protocolVersion20250618 = "2025-06-18"
const serverName = "Toolbox"
var tool1InputSchema = map[string]any{
@@ -254,7 +255,7 @@ func TestMcpEndpoint(t *testing.T) {
initWant map[string]any
}{
{
name: "verson 2024-11-05",
name: "version 2024-11-05",
protocol: protocolVersion20241105,
idHeader: false,
initWant: map[string]any{
@@ -270,7 +271,7 @@ func TestMcpEndpoint(t *testing.T) {
},
},
{
name: "verson 2025-03-26",
name: "version 2025-03-26",
protocol: protocolVersion20250326,
idHeader: true,
initWant: map[string]any{
@@ -285,6 +286,22 @@ func TestMcpEndpoint(t *testing.T) {
},
},
},
{
name: "version 2025-06-18",
protocol: protocolVersion20250618,
idHeader: false,
initWant: map[string]any{
"jsonrpc": "2.0",
"id": "mcp-initialize",
"result": map[string]any{
"protocolVersion": "2025-06-18",
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
},
"serverInfo": map[string]any{"name": serverName, "version": fakeVersionString},
},
},
},
}
for _, vtc := range versTestCases {
t.Run(vtc.name, func(t *testing.T) {
@@ -295,6 +312,10 @@ func TestMcpEndpoint(t *testing.T) {
header["Mcp-Session-Id"] = sessionId
}
if vtc.protocol == protocolVersion20250618 {
header["MCP-Protocol-Version"] = vtc.protocol
}
testCases := []struct {
name string
url string
@@ -481,7 +502,7 @@ func TestMcpEndpoint(t *testing.T) {
t.Fatalf("unexpected error during marshaling of body")
}
if vtc.protocol == protocolVersion20250326 && len(header) == 0 {
if vtc.protocol != protocolVersion20241105 && len(header) == 0 {
t.Fatalf("header is missing")
}
@@ -514,6 +535,33 @@ func TestMcpEndpoint(t *testing.T) {
}
}
func TestInvalidProtocolVersionHeader(t *testing.T) {
toolsMap, toolsets := map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
header := map[string]string{}
header["MCP-Protocol-Version"] = "foo"
resp, body, err := runRequest(ts, http.MethodPost, "/", nil, header)
if resp.Status != "400 Bad Request" {
t.Fatalf("unexpected status: %s", resp.Status)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unexpected error unmarshalling body: %s", err)
}
want := "invalid protocol version: foo"
if got["error"] != want {
t.Fatalf("unexpected error message: got %s, want %s", got["error"], want)
}
if err != nil {
t.Fatalf("unexpected error during request: %s", err)
}
}
func TestDeleteEndpoint(t *testing.T) {
toolsMap, toolsets := map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)

View File

@@ -109,11 +109,17 @@ func parseFromAuthService(paramAuthServices []ParamAuthService, claimsMap map[st
return nil, fmt.Errorf("missing or invalid authentication header")
}
// CheckParamRequired checks if a parameter is required based on the required and default field.
func CheckParamRequired(required bool, defaultV any) bool {
return required && defaultV == nil
}
// ParseParams is a helper function for parsing Parameters from an arbitraryJSON object.
func ParseParams(ps Parameters, data map[string]any, claimsMap map[string]map[string]any) (ParamValues, error) {
params := make([]ParamValue, 0, len(ps))
for _, p := range ps {
var v any
var v, newV any
var err error
paramAuthServices := p.GetAuthServices()
name := p.GetName()
if len(paramAuthServices) == 0 {
@@ -122,21 +128,23 @@ func ParseParams(ps Parameters, data map[string]any, claimsMap map[string]map[st
v, ok = data[name]
if !ok {
v = p.GetDefault()
if v == nil {
// if the parameter is required and no value given, throw an error
if CheckParamRequired(p.GetRequired(), v) {
return nil, fmt.Errorf("parameter %q is required", name)
}
}
} else {
// parse authenticated parameter
var err error
v, err = parseFromAuthService(paramAuthServices, claimsMap)
if err != nil {
return nil, fmt.Errorf("error parsing authenticated parameter %q: %w", name, err)
}
}
newV, err := p.Parse(v)
if err != nil {
return nil, fmt.Errorf("unable to parse value for %q: %w", name, err)
if v != nil {
newV, err = p.Parse(v)
if err != nil {
return nil, fmt.Errorf("unable to parse value for %q: %w", name, err)
}
}
params = append(params, ParamValue{Name: name, Value: newV})
}
@@ -248,6 +256,7 @@ type Parameter interface {
GetName() string
GetType() string
GetDefault() any
GetRequired() bool
GetAuthServices() []ParamAuthService
Parse(any) (any, error)
Manifest() ParameterManifest
@@ -378,7 +387,7 @@ func (ps Parameters) McpManifest() McpToolsSchema {
name := p.GetName()
properties[name] = p.McpManifest()
// parameters that doesn't have a default value are added to the required field
if p.GetDefault() == nil {
if CheckParamRequired(p.GetRequired(), p.GetDefault()) {
required = append(required, name)
}
}
@@ -412,6 +421,7 @@ type CommonParameter struct {
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
Desc string `yaml:"description" validate:"required"`
Required *bool `yaml:"required"`
AuthServices []ParamAuthService `yaml:"authServices"`
AuthSources []ParamAuthService `yaml:"authSources"` // Deprecated: Kept for compatibility.
}
@@ -426,6 +436,15 @@ func (p *CommonParameter) GetType() string {
return p.Type
}
// GetRequired returns the type specified for the Parameter.
func (p *CommonParameter) GetRequired() bool {
// parameters are defaulted to required
if p.Required == nil {
return true
}
return *p.Required
}
// McpManifest returns the MCP manifest for the Parameter.
func (p *CommonParameter) McpManifest() ParameterMcpManifest {
return ParameterMcpManifest{
@@ -475,6 +494,19 @@ func NewStringParameterWithDefault(name string, defaultV, desc string) *StringPa
}
}
// NewStringParameterWithRequired is a convenience function for initializing a StringParameter.
func NewStringParameterWithRequired(name string, desc string, required bool) *StringParameter {
return &StringParameter{
CommonParameter: CommonParameter{
Name: name,
Type: typeString,
Desc: desc,
Required: &required,
AuthServices: nil,
},
}
}
// NewStringParameterWithAuth is a convenience function for initializing a StringParameter with a list of ParamAuthService.
func NewStringParameterWithAuth(name string, desc string, authServices []ParamAuthService) *StringParameter {
return &StringParameter{
@@ -522,11 +554,11 @@ func (p *StringParameter) Manifest() ParameterManifest {
for i, a := range p.AuthServices {
authNames[i] = a.Name
}
required := p.Default == nil
r := CheckParamRequired(p.GetRequired(), p.GetDefault())
return ParameterManifest{
Name: p.Name,
Type: p.Type,
Required: required,
Required: r,
Description: p.Desc,
AuthServices: authNames,
}
@@ -557,6 +589,19 @@ func NewIntParameterWithDefault(name string, defaultV int, desc string) *IntPara
}
}
// NewIntParameterWithRequired is a convenience function for initializing a IntParameter.
func NewIntParameterWithRequired(name string, desc string, required bool) *IntParameter {
return &IntParameter{
CommonParameter: CommonParameter{
Name: name,
Type: typeInt,
Desc: desc,
Required: &required,
AuthServices: nil,
},
}
}
// NewIntParameterWithAuth is a convenience function for initializing a IntParameter with a list of ParamAuthService.
func NewIntParameterWithAuth(name string, desc string, authServices []ParamAuthService) *IntParameter {
return &IntParameter{
@@ -616,11 +661,11 @@ func (p *IntParameter) Manifest() ParameterManifest {
for i, a := range p.AuthServices {
authNames[i] = a.Name
}
required := p.Default == nil
r := CheckParamRequired(p.GetRequired(), p.GetDefault())
return ParameterManifest{
Name: p.Name,
Type: p.Type,
Required: required,
Required: r,
Description: p.Desc,
AuthServices: authNames,
}
@@ -651,6 +696,19 @@ func NewFloatParameterWithDefault(name string, defaultV float64, desc string) *F
}
}
// NewFloatParameterWithRequired is a convenience function for initializing a FloatParameter.
func NewFloatParameterWithRequired(name string, desc string, required bool) *FloatParameter {
return &FloatParameter{
CommonParameter: CommonParameter{
Name: name,
Type: typeFloat,
Desc: desc,
Required: &required,
AuthServices: nil,
},
}
}
// NewFloatParameterWithAuth is a convenience function for initializing a FloatParameter with a list of ParamAuthService.
func NewFloatParameterWithAuth(name string, desc string, authServices []ParamAuthService) *FloatParameter {
return &FloatParameter{
@@ -708,11 +766,11 @@ func (p *FloatParameter) Manifest() ParameterManifest {
for i, a := range p.AuthServices {
authNames[i] = a.Name
}
required := p.Default == nil
r := CheckParamRequired(p.GetRequired(), p.GetDefault())
return ParameterManifest{
Name: p.Name,
Type: p.Type,
Required: required,
Required: r,
Description: p.Desc,
AuthServices: authNames,
}
@@ -743,6 +801,19 @@ func NewBooleanParameterWithDefault(name string, defaultV bool, desc string) *Bo
}
}
// NewBooleanParameterWithRequired is a convenience function for initializing a BooleanParameter.
func NewBooleanParameterWithRequired(name string, desc string, required bool) *BooleanParameter {
return &BooleanParameter{
CommonParameter: CommonParameter{
Name: name,
Type: typeBool,
Desc: desc,
Required: &required,
AuthServices: nil,
},
}
}
// NewBooleanParameterWithAuth is a convenience function for initializing a BooleanParameter with a list of ParamAuthService.
func NewBooleanParameterWithAuth(name string, desc string, authServices []ParamAuthService) *BooleanParameter {
return &BooleanParameter{
@@ -789,11 +860,11 @@ func (p *BooleanParameter) Manifest() ParameterManifest {
for i, a := range p.AuthServices {
authNames[i] = a.Name
}
required := p.Default == nil
r := CheckParamRequired(p.GetRequired(), p.GetDefault())
return ParameterManifest{
Name: p.Name,
Type: p.Type,
Required: required,
Required: r,
Description: p.Desc,
AuthServices: authNames,
}
@@ -826,6 +897,20 @@ func NewArrayParameterWithDefault(name string, defaultV []any, desc string, item
}
}
// NewArrayParameterWithRequired is a convenience function for initializing a ArrayParameter with default value.
func NewArrayParameterWithRequired(name string, desc string, required bool, items Parameter) *ArrayParameter {
return &ArrayParameter{
CommonParameter: CommonParameter{
Name: name,
Type: typeArray,
Desc: desc,
Required: &required,
AuthServices: nil,
},
Items: items,
}
}
// NewArrayParameterWithAuth is a convenience function for initializing a ArrayParameter with a list of ParamAuthService.
func NewArrayParameterWithAuth(name string, desc string, items Parameter, authServices []ParamAuthService) *ArrayParameter {
return &ArrayParameter{
@@ -910,12 +995,13 @@ func (p *ArrayParameter) Manifest() ParameterManifest {
authNames[i] = a.Name
}
items := p.Items.Manifest()
required := p.Default == nil
items.Required = required
// if required value is true, or there's no default value
r := CheckParamRequired(p.GetRequired(), p.GetDefault())
items.Required = r
return ParameterManifest{
Name: p.Name,
Type: p.Type,
Required: required,
Required: r,
Description: p.Desc,
AuthServices: authNames,
Items: &items,

View File

@@ -50,6 +50,20 @@ func TestParametersMarshal(t *testing.T) {
tools.NewStringParameter("my_string", "this param is a string"),
},
},
{
name: "string not required",
in: []map[string]any{
{
"name": "my_string",
"type": "string",
"description": "this param is a string",
"required": false,
},
},
want: tools.Parameters{
tools.NewStringParameterWithRequired("my_string", "this param is a string", false),
},
},
{
name: "int",
in: []map[string]any{
@@ -63,6 +77,20 @@ func TestParametersMarshal(t *testing.T) {
tools.NewIntParameter("my_integer", "this param is an int"),
},
},
{
name: "int not required",
in: []map[string]any{
{
"name": "my_integer",
"type": "integer",
"description": "this param is an int",
"required": false,
},
},
want: tools.Parameters{
tools.NewIntParameterWithRequired("my_integer", "this param is an int", false),
},
},
{
name: "float",
in: []map[string]any{
@@ -76,6 +104,20 @@ func TestParametersMarshal(t *testing.T) {
tools.NewFloatParameter("my_float", "my param is a float"),
},
},
{
name: "float not required",
in: []map[string]any{
{
"name": "my_float",
"type": "float",
"description": "my param is a float",
"required": false,
},
},
want: tools.Parameters{
tools.NewFloatParameterWithRequired("my_float", "my param is a float", false),
},
},
{
name: "bool",
in: []map[string]any{
@@ -89,6 +131,20 @@ func TestParametersMarshal(t *testing.T) {
tools.NewBooleanParameter("my_bool", "this param is a boolean"),
},
},
{
name: "bool not required",
in: []map[string]any{
{
"name": "my_bool",
"type": "boolean",
"description": "this param is a boolean",
"required": false,
},
},
want: tools.Parameters{
tools.NewBooleanParameterWithRequired("my_bool", "this param is a boolean", false),
},
},
{
name: "string array",
in: []map[string]any{
@@ -107,6 +163,25 @@ func TestParametersMarshal(t *testing.T) {
tools.NewArrayParameter("my_array", "this param is an array of strings", tools.NewStringParameter("my_string", "string item")),
},
},
{
name: "string array not required",
in: []map[string]any{
{
"name": "my_array",
"type": "array",
"description": "this param is an array of strings",
"required": false,
"items": map[string]string{
"name": "my_string",
"type": "string",
"description": "string item",
},
},
},
want: tools.Parameters{
tools.NewArrayParameterWithRequired("my_array", "this param is an array of strings", false, tools.NewStringParameter("my_string", "string item")),
},
},
{
name: "float array",
in: []map[string]any{
@@ -673,6 +748,38 @@ func TestParametersParse(t *testing.T) {
in: map[string]any{},
want: tools.ParamValues{tools.ParamValue{Name: "my_bool", Value: true}},
},
{
name: "string not required",
params: tools.Parameters{
tools.NewStringParameterWithRequired("my_string", "this param is a string", false),
},
in: map[string]any{},
want: tools.ParamValues{tools.ParamValue{Name: "my_string", Value: nil}},
},
{
name: "int not required",
params: tools.Parameters{
tools.NewIntParameterWithRequired("my_int", "this param is an int", false),
},
in: map[string]any{},
want: tools.ParamValues{tools.ParamValue{Name: "my_int", Value: nil}},
},
{
name: "float not required",
params: tools.Parameters{
tools.NewFloatParameterWithRequired("my_float", "this param is a float", false),
},
in: map[string]any{},
want: tools.ParamValues{tools.ParamValue{Name: "my_float", Value: nil}},
},
{
name: "bool not required",
params: tools.Parameters{
tools.NewBooleanParameterWithRequired("my_bool", "this param is a bool", false),
},
in: map[string]any{},
want: tools.ParamValues{tools.ParamValue{Name: "my_bool", Value: nil}},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
@@ -1003,6 +1110,38 @@ func TestParamManifest(t *testing.T) {
Items: &tools.ParameterManifest{Name: "foo-string", Type: "string", Required: false, Description: "bar", AuthServices: []string{}},
},
},
{
name: "string not required",
in: tools.NewStringParameterWithRequired("foo-string", "bar", false),
want: tools.ParameterManifest{Name: "foo-string", Type: "string", Required: false, Description: "bar", AuthServices: []string{}},
},
{
name: "int not required",
in: tools.NewIntParameterWithRequired("foo-int", "bar", false),
want: tools.ParameterManifest{Name: "foo-int", Type: "integer", Required: false, Description: "bar", AuthServices: []string{}},
},
{
name: "float not required",
in: tools.NewFloatParameterWithRequired("foo-float", "bar", false),
want: tools.ParameterManifest{Name: "foo-float", Type: "float", Required: false, Description: "bar", AuthServices: []string{}},
},
{
name: "boolean not required",
in: tools.NewBooleanParameterWithRequired("foo-bool", "bar", false),
want: tools.ParameterManifest{Name: "foo-bool", Type: "boolean", Required: false, Description: "bar", AuthServices: []string{}},
},
{
name: "array not required",
in: tools.NewArrayParameterWithRequired("foo-array", "bar", false, tools.NewStringParameter("foo-string", "bar")),
want: tools.ParameterManifest{
Name: "foo-array",
Type: "array",
Required: false,
Description: "bar",
AuthServices: []string{},
Items: &tools.ParameterManifest{Name: "foo-string", Type: "string", Required: false, Description: "bar", AuthServices: []string{}},
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
@@ -1071,6 +1210,8 @@ func TestMcpManifest(t *testing.T) {
in: tools.Parameters{
tools.NewStringParameterWithDefault("foo-string", "foo", "bar"),
tools.NewStringParameter("foo-string2", "bar"),
tools.NewStringParameterWithRequired("foo-string-req", "bar", true),
tools.NewStringParameterWithRequired("foo-string-not-req", "bar", false),
tools.NewIntParameterWithDefault("foo-int", 1, "bar"),
tools.NewIntParameter("foo-int2", "bar"),
tools.NewArrayParameterWithDefault("foo-array", []any{"hello", "world"}, "bar", tools.NewStringParameter("foo-string", "bar")),
@@ -1079,10 +1220,12 @@ func TestMcpManifest(t *testing.T) {
want: tools.McpToolsSchema{
Type: "object",
Properties: map[string]tools.ParameterMcpManifest{
"foo-string": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-string2": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-int": tools.ParameterMcpManifest{Type: "integer", Description: "bar"},
"foo-int2": tools.ParameterMcpManifest{Type: "integer", Description: "bar"},
"foo-string": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-string2": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-string-req": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-string-not-req": tools.ParameterMcpManifest{Type: "string", Description: "bar"},
"foo-int": tools.ParameterMcpManifest{Type: "integer", Description: "bar"},
"foo-int2": tools.ParameterMcpManifest{Type: "integer", Description: "bar"},
"foo-array": tools.ParameterMcpManifest{
Type: "array",
Description: "bar",
@@ -1094,7 +1237,7 @@ func TestMcpManifest(t *testing.T) {
Items: &tools.ParameterMcpManifest{Type: "string", Description: "bar"},
},
},
Required: []string{"foo-string2", "foo-int2", "foo-array2"},
Required: []string{"foo-string2", "foo-string-req", "foo-int2", "foo-array2"},
},
},
}
@@ -1502,3 +1645,45 @@ func TestFailResolveTemplateParameters(t *testing.T) {
})
}
}
func TestCheckParamRequired(t *testing.T) {
tcs := []struct {
name string
required bool
defaultV any
want bool
}{
{
name: "required and no default",
required: true,
defaultV: nil,
want: true,
},
{
name: "required and default",
required: true,
defaultV: "foo",
want: false,
},
{
name: "not required and no default",
required: false,
defaultV: nil,
want: false,
},
{
name: "not required and default",
required: false,
defaultV: "foo",
want: false,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
got := tools.CheckParamRequired(tc.required, tc.defaultV)
if got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,209 @@
// 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 setenvvariable
import (
"context"
"encoding/json"
"fmt"
"os"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
)
const kind string = "update-mcp-settings"
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
projectIDParam := tools.NewStringParameter("ALLOYDB_POSTGRES_PROJECT", "The Google Cloud project ID.")
regionParam := tools.NewStringParameter("ALLOYDB_POSTGRES_REGION", "The region for AlloyDB.")
clusterParam := tools.NewStringParameter("ALLOYDB_POSTGRES_CLUSTER", "The AlloyDB cluster name.")
instanceParam := tools.NewStringParameter("ALLOYDB_POSTGRES_INSTANCE", "The AlloyDB instance name.")
databaseParam := tools.NewStringParameter("ALLOYDB_POSTGRES_DATABASE", "The AlloyDB database name (defaults to 'postgres').")
userParam := tools.NewStringParameter("ALLOYDB_POSTGRES_USER", "The database username.")
passwordParam := tools.NewStringParameter("ALLOYDB_POSTGRES_PASSWORD", "The database password.")
mcpSettingsFile := tools.NewStringParameter("mcpSettingsFile", "The MCP Settings json file which contains information about server to run for the IDE")
parameters := tools.Parameters{
projectIDParam,
regionParam,
clusterParam,
instanceParam,
databaseParam,
userParam,
passwordParam,
mcpSettingsFile,
}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: parameters.McpManifest(),
}
t := Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
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
Kind string
Parameters tools.Parameters
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) ([]any, error) {
paramsMap := params.AsMap()
mcpSettingsFile, ok := paramsMap["mcpSettingsFile"]
if !ok {
return nil, fmt.Errorf("mcpSettingsFile not found in params")
}
mcpSettingsFileStr, ok := mcpSettingsFile.(string)
if !ok {
return nil, fmt.Errorf("mcpSettingsFile is not a string")
}
data, err := os.ReadFile(mcpSettingsFileStr)
if err != nil {
return nil, fmt.Errorf("failed to read mcp settings file: %w", err)
}
var mcpSettings map[string]interface{}
if err := json.Unmarshal(data, &mcpSettings); err != nil {
return nil, fmt.Errorf("failed to unmarshal mcp settings file: %w", err)
}
mcpServers, ok := mcpSettings["mcpServers"].(map[string]interface{})
if !ok {
if servers, found := mcpSettings["servers"].(map[string]interface{}); found {
mcpServers = servers
} else {
mcpServers = make(map[string]interface{})
mcpSettings["mcpServers"] = mcpServers
}
}
var targetServer map[string]interface{}
var targetServerName string
for serverName, server := range mcpServers {
serverMap, ok := server.(map[string]interface{})
if !ok {
continue
}
args, ok := serverMap["args"].([]interface{})
if !ok {
continue
}
for _, arg := range args {
if argStr, ok := arg.(string); ok && argStr == "alloydb-postgres" {
targetServer = serverMap
targetServerName = serverName
break
}
}
if targetServer != nil {
break
}
}
if targetServer == nil {
targetServerName = "alloydb"
targetServer = make(map[string]interface{})
targetServer["args"] = []interface{}{"--prebuilt", "alloydb-postgres", "--stdio"}
mcpServers[targetServerName] = targetServer
}
if _, ok := targetServer["command"]; !ok {
targetServer["command"] = "./PATH/TO/toolbox"
}
env, ok := targetServer["env"].(map[string]interface{})
if !ok {
env = make(map[string]interface{})
targetServer["env"] = env
}
for key, value := range paramsMap {
if key != "mcpSettingsFile" {
env[key] = value
}
}
updatedData, err := json.MarshalIndent(mcpSettings, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal mcp settings file: %w", err)
}
if err := os.WriteFile(mcpSettingsFileStr, updatedData, 0644); err != nil {
return nil, fmt.Errorf("failed to write mcp settings file: %w", err)
}
return []any{"Successfully updated MCP settings file"}, 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 true
}

View File

@@ -0,0 +1,72 @@
// 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 setenvvariable_test
import (
"testing"
"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"
setenvvariable "github.com/googleapis/genai-toolbox/internal/tools/utility/envvariable"
)
func TestParseFromYamlSetEnvVariable(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: update-mcp-settings
description: some description
authRequired:
- my-google-auth-service
`,
want: server.ToolConfigs{
"example_tool": setenvvariable.Config{
Name: "example_tool",
Kind: "update-mcp-settings",
Description: "some description",
AuthRequired: []string{"my-google-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,120 @@
// 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 wait
import (
"context"
"fmt"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
)
const kind string = "wait"
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Description string `yaml:"description" validate:"required"`
Timeout string `yaml:"timeout" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(_ map[string]sources.Source) (tools.Tool, error) {
durationParameter := tools.NewStringParameter("duration", "The duration to wait for, specified as a string (e.g., '10s', '2m', '1h').")
parameters := tools.Parameters{durationParameter}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: parameters.McpManifest(),
}
t := Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
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
Kind string
Parameters tools.Parameters
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) ([]any, error) {
paramsMap := params.AsMap()
durationStr, ok := paramsMap["duration"].(string)
if !ok {
return nil, fmt.Errorf("duration parameter is not a string")
}
totalDuration, err := time.ParseDuration(durationStr)
if err != nil {
return nil, fmt.Errorf("invalid duration format: %w", err)
}
time.Sleep(totalDuration)
return []any{fmt.Sprintf("Wait for %v completed successfully.", totalDuration)}, 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 true
}

View File

@@ -0,0 +1,75 @@
// 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 wait_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"
wait "github.com/googleapis/genai-toolbox/internal/tools/utility/wait"
)
func TestParseFromYamlWait(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: wait
description: some description
timeout: 10s
authRequired:
- my-google-auth-service
`,
want: server.ToolConfigs{
"example_tool": wait.Config{
Name: "example_tool",
Kind: "wait",
Description: "some description",
Timeout: "10s",
AuthRequired: []string{"my-google-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

@@ -67,7 +67,7 @@ func StartCmd(ctx context.Context, toolsFile map[string]any, args ...string) (*C
if err != nil {
return nil, nil, fmt.Errorf("unable to write tools file: %s", err)
}
args = append(args, "--tools_file", path)
args = append(args, "--tools-file", path)
ctx, cancel := context.WithCancel(ctx)
// Open a pipe for tracking the output from the cmd

View File

@@ -0,0 +1,225 @@
// 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 utility_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"time"
"github.com/googleapis/genai-toolbox/internal/testutils"
_ "github.com/googleapis/genai-toolbox/internal/tools/utility/envvariable"
"github.com/googleapis/genai-toolbox/tests"
)
func TestUpdateMCPSettings(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
toolName := "my-update-mcp-settings"
mcpSettings := map[string]interface{}{
"mcpServers": map[string]interface{}{},
}
mcpSettingsData, err := json.Marshal(mcpSettings)
if err != nil {
t.Fatalf("failed to marshal mcp settings: %v", err)
}
tmpDir := t.TempDir()
mcpSettingsFile := filepath.Join(tmpDir, "mcp.json")
if err := os.WriteFile(mcpSettingsFile, mcpSettingsData, 0644); err != nil {
t.Fatalf("failed to write mcp settings file: %v", err)
}
toolsFile := map[string]any{
"tools": map[string]any{
toolName: map[string]any{
"kind": "update-mcp-settings",
"description": "Update MCP settings.",
},
},
}
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile)
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)
}
t.Run("success", func(t *testing.T) {
params := map[string]interface{}{
"mcpSettingsFile": mcpSettingsFile,
"ALLOYDB_POSTGRES_PROJECT": "my-project",
"ALLOYDB_POSTGRES_REGION": "my-region",
"ALLOYDB_POSTGRES_CLUSTER": "my-cluster",
"ALLOYDB_POSTGRES_INSTANCE": "my-instance",
"ALLOYDB_POSTGRES_DATABASE": "my-db",
"ALLOYDB_POSTGRES_USER": "my-user",
"ALLOYDB_POSTGRES_PASSWORD": "my-password",
}
var result struct{ Result string }
if err := invoke(toolName, params, &result); err != nil {
t.Fatalf("tool invocation failed: %v", err)
}
expectedResult := "[\"Successfully updated MCP settings file\"]"
if !reflect.DeepEqual(result.Result, expectedResult) {
t.Errorf("unexpected result: got %q, want %q", result.Result, expectedResult)
}
data, err := os.ReadFile(mcpSettingsFile)
if err != nil {
t.Fatalf("failed to read mcp settings file: %v", err)
}
var updatedMCPSettings map[string]interface{}
if err := json.Unmarshal(data, &updatedMCPSettings); err != nil {
t.Fatalf("failed to unmarshal mcp settings file: %v", err)
}
mcpServers, ok := updatedMCPSettings["mcpServers"].(map[string]interface{})
if !ok {
t.Fatalf("mcpServers not found in updated settings")
}
alloydbServer, ok := mcpServers["alloydb"].(map[string]interface{})
if !ok {
t.Fatalf("alloydb server not found in updated settings")
}
env, ok := alloydbServer["env"].(map[string]interface{})
if !ok {
t.Fatalf("env not found in alloydb server settings")
}
expectedEnv := map[string]interface{}{
"ALLOYDB_POSTGRES_PROJECT": "my-project",
"ALLOYDB_POSTGRES_REGION": "my-region",
"ALLOYDB_POSTGRES_CLUSTER": "my-cluster",
"ALLOYDB_POSTGRES_INSTANCE": "my-instance",
"ALLOYDB_POSTGRES_DATABASE": "my-db",
"ALLOYDB_POSTGRES_USER": "my-user",
"ALLOYDB_POSTGRES_PASSWORD": "my-password",
}
if !reflect.DeepEqual(env, expectedEnv) {
t.Errorf("unexpected env: got %v, want %v", env, expectedEnv)
}
})
t.Run("file not found", func(t *testing.T) {
params := map[string]interface{}{
"mcpSettingsFile": "non-existent-file.json",
"ALLOYDB_POSTGRES_PROJECT": "my-project",
"ALLOYDB_POSTGRES_REGION": "my-region",
"ALLOYDB_POSTGRES_CLUSTER": "my-cluster",
"ALLOYDB_POSTGRES_INSTANCE": "my-instance",
"ALLOYDB_POSTGRES_DATABASE": "my-db",
"ALLOYDB_POSTGRES_USER": "my-user",
"ALLOYDB_POSTGRES_PASSWORD": "my-password",
}
var result struct{ Result string }
err := invoke(toolName, params, &result)
if err == nil {
t.Fatal("expected an error but got none")
}
expectedError := "failed to read mcp settings file"
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("unexpected error: got %v, want to contain %v", err, expectedError)
}
})
t.Run("invalid json", func(t *testing.T) {
invalidJSONFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(invalidJSONFile, []byte("{"), 0644); err != nil {
t.Fatalf("failed to write invalid json file: %v", err)
}
params := map[string]interface{}{
"mcpSettingsFile": invalidJSONFile,
"ALLOYDB_POSTGRES_PROJECT": "my-project",
"ALLOYDB_POSTGRES_REGION": "my-region",
"ALLOYDB_POSTGRES_CLUSTER": "my-cluster",
"ALLOYDB_POSTGRES_INSTANCE": "my-instance",
"ALLOYDB_POSTGRES_DATABASE": "my-db",
"ALLOYDB_POSTGRES_USER": "my-user",
"ALLOYDB_POSTGRES_PASSWORD": "my-password",
}
var result struct{ Result string }
err := invoke(toolName, params, &result)
if err == nil {
t.Fatal("expected an error but got none")
}
expectedError := "failed to unmarshal mcp settings file"
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("unexpected error: got %v, want to contain %v", err, expectedError)
}
})
t.Run("missing mcpSettingsFile parameter", func(t *testing.T) {
params := map[string]interface{}{
"ALLOYDB_POSTGRES_PROJECT": "my-project",
"ALLOYDB_POSTGRES_REGION": "my-region",
"ALLOYDB_POSTGRES_CLUSTER": "my-cluster",
"ALLOYDB_POSTGRES_INSTANCE": "my-instance",
"ALLOYDB_POSTGRES_DATABASE": "my-db",
"ALLOYDB_POSTGRES_USER": "my-user",
"ALLOYDB_POSTGRES_PASSWORD": "my-password",
}
var result struct{ Result string }
err := invoke(toolName, params, &result)
if err == nil {
t.Fatal("expected an error but got none")
}
expectedError := "parameter \\\"mcpSettingsFile\\\" is required"
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("unexpected error: got %v, want to contain %v", err, expectedError)
}
})
}
func invoke(toolName string, params map[string]interface{}, result interface{}) error {
url := fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", toolName)
body, err := json.Marshal(params)
if err != nil {
return err
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(respBody))
}
return json.NewDecoder(resp.Body).Decode(result)
}

View File

@@ -0,0 +1,118 @@
// 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 utility
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"regexp"
"testing"
"time"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
)
func RunWaitTool(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
args := []string{"--port", "5001"}
toolsFile := map[string]any{
"tools": map[string]any{
"my-wait-for-tool": map[string]any{
"kind": "wait",
"description": "Wait for a specified duration.",
"timeout": "30s",
},
},
}
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)
}
invokeTcs := []struct {
name string
api string
requestBody io.Reader
want string
isErr bool
}{
{
name: "invoke my-wait-for-tool",
api: "http://127.0.0.1:5001/api/tool/my-wait-for-tool/invoke",
requestBody: bytes.NewBuffer([]byte(`{"duration": "1s"}`)),
want: `["Wait for 1s completed successfully."]`,
isErr: false,
},
{
name: "invoke my-wait-for-tool with invalid duration",
api: "http://127.0.0.1:5001/api/tool/my-wait-for-tool/invoke",
requestBody: bytes.NewBuffer([]byte(`{"duration": "invalid"}`)),
isErr: true,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if tc.isErr {
return
}
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body")
}
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)
}
})
}
}