Compare commits

..

13 Commits

Author SHA1 Message Date
Twisha Bansal
61073c0f59 Merge branch 'main' into quickstart-ci-refactor 2026-02-05 09:53:39 +05:30
Twisha Bansal
56714e3f89 Apply suggestion from @Yuan325
Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
2026-02-05 09:47:33 +05:30
Haoyu Wang
80ef346214 feat(cli/skills): add support for generating agent skills from toolset (#2392)
## Description

This PR introduces a new skills-generate command that enables users to
generate standardized agent skills from their existing Toolbox tool
configurations. This facilitates the integration of Toolbox tools into
agentic workflows by automatically creating skill descriptions
(SKILL.md) and executable wrappers.
- New Subcommand: Implemented skills-generate, which automates the
creation of agent skill packages including metadata and executable
scripts.
- Skill Generation: Added logic to generate SKILL.md files with
parameter schemas and Node.js wrappers for cross-platform tool
execution.
- Toolset Integration: Supports selective generation of skills based on
defined toolsets, including support for both local files and prebuilt
configurations.
- Testing: Added unit tests for the generation logic and
integration tests for the CLI command.
- Documentation: Created a new "how-to" guide for generating skills and
updated the CLI reference documentation.
2026-02-04 15:51:14 -05:00
Twisha Bansal
35fa73516b rename file 2026-02-03 15:45:03 +05:30
Twisha Bansal
66df3bfd21 move file around fix 2026-02-03 15:40:49 +05:30
Twisha Bansal
73e0edc3cd move files around 2026-02-03 15:36:56 +05:30
Twisha Bansal
3f32a9aab6 fix paths 2026-02-03 15:29:18 +05:30
Twisha Bansal
28006fc9b2 move files around 2026-02-03 15:27:43 +05:30
Twisha Bansal
56c69131b4 Update run_tests.sh 2026-02-03 15:09:07 +05:30
Twisha Bansal
ad4a509340 Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 14:16:47 +05:30
Twisha Bansal
d39acac96c Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 14:16:36 +05:30
Twisha Bansal
6df2ad28a9 Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 14:15:54 +05:30
Twisha Bansal
8416378613 refactor: consolidate quickstart tests into universal runner 2026-02-03 14:11:54 +05:30
29 changed files with 1591 additions and 1478 deletions

View File

@@ -376,26 +376,6 @@ steps:
spanner \
spanner || echo "Integration tests failed." # ignore test failures
- id: "spanner-admin"
name: golang:1
waitFor: ["compile-test-binary"]
entrypoint: /bin/bash
env:
- "GOPATH=/gopath"
- "SPANNER_PROJECT=$PROJECT_ID"
- "SERVICE_ACCOUNT_EMAIL=$SERVICE_ACCOUNT_EMAIL"
secretEnv: ["CLIENT_ID"]
volumes:
- name: "go"
path: "/gopath"
args:
- -c
- |
.ci/test_with_coverage.sh \
"Spanner Admin" \
spanneradmin \
spanneradmin || echo "Integration tests failed."
- id: "neo4j"
name: golang:1
waitFor: ["compile-test-binary"]

View File

@@ -1,125 +0,0 @@
# 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.
#!/bin/bash
set -e
TABLE_NAME="hotels_go"
QUICKSTART_GO_DIR="docs/en/getting-started/quickstart/go"
SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql"
PROXY_PID=""
TOOLBOX_PID=""
install_system_packages() {
apt-get update && apt-get install -y \
postgresql-client \
wget \
gettext-base \
netcat-openbsd
}
start_cloud_sql_proxy() {
wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy
chmod +x /usr/local/bin/cloud-sql-proxy
cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" &
PROXY_PID=$!
for i in {1..30}; do
if nc -z 127.0.0.1 5432; then
echo "Cloud SQL Proxy is up and running."
return
fi
sleep 1
done
echo "Cloud SQL Proxy failed to start within the timeout period."
exit 1
}
setup_toolbox() {
TOOLBOX_YAML="/tools.yaml"
echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML"
if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi
wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox"
chmod +x "/toolbox"
/toolbox --tools-file "$TOOLBOX_YAML" &
TOOLBOX_PID=$!
sleep 2
}
setup_orch_table() {
export TABLE_NAME
envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME"
}
run_orch_test() {
local orch_dir="$1"
local orch_name
orch_name=$(basename "$orch_dir")
if [ "$orch_name" == "openAI" ]; then
echo -e "\nSkipping framework '${orch_name}': Temporarily excluded."
return
fi
(
set -e
setup_orch_table
echo "--- Preparing module for $orch_name ---"
cd "$orch_dir"
if [ -f "go.mod" ]; then
go mod tidy
fi
cd ..
export ORCH_NAME="$orch_name"
echo "--- Running tests for $orch_name ---"
go test -v ./...
)
}
cleanup_all() {
echo "--- Final cleanup: Shutting down processes and dropping table ---"
if [ -n "$TOOLBOX_PID" ]; then
kill $TOOLBOX_PID || true
fi
if [ -n "$PROXY_PID" ]; then
kill $PROXY_PID || true
fi
}
trap cleanup_all EXIT
# Main script execution
install_system_packages
start_cloud_sql_proxy
export PGHOST=127.0.0.1
export PGPORT=5432
export PGPASSWORD="$DB_PASSWORD"
export GOOGLE_API_KEY="$GOOGLE_API_KEY"
setup_toolbox
for ORCH_DIR in "$QUICKSTART_GO_DIR"/*/; do
if [ ! -d "$ORCH_DIR" ]; then
continue
fi
run_orch_test "$ORCH_DIR"
done

View File

@@ -1,125 +0,0 @@
# 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.
#!/bin/bash
set -e
TABLE_NAME="hotels_js"
QUICKSTART_JS_DIR="docs/en/getting-started/quickstart/js"
SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql"
# Initialize process IDs to empty at the top of the script
PROXY_PID=""
TOOLBOX_PID=""
install_system_packages() {
apt-get update && apt-get install -y \
postgresql-client \
wget \
gettext-base \
netcat-openbsd
}
start_cloud_sql_proxy() {
wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy
chmod +x /usr/local/bin/cloud-sql-proxy
cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" &
PROXY_PID=$!
for i in {1..30}; do
if nc -z 127.0.0.1 5432; then
echo "Cloud SQL Proxy is up and running."
return
fi
sleep 1
done
echo "Cloud SQL Proxy failed to start within the timeout period."
exit 1
}
setup_toolbox() {
TOOLBOX_YAML="/tools.yaml"
echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML"
if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi
wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox"
chmod +x "/toolbox"
/toolbox --tools-file "$TOOLBOX_YAML" &
TOOLBOX_PID=$!
sleep 2
}
setup_orch_table() {
export TABLE_NAME
envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME"
}
run_orch_test() {
local orch_dir="$1"
local orch_name
orch_name=$(basename "$orch_dir")
(
set -e
echo "--- Preparing environment for $orch_name ---"
setup_orch_table
cd "$orch_dir"
echo "Installing dependencies for $orch_name..."
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi
cd ..
echo "--- Running tests for $orch_name ---"
export ORCH_NAME="$orch_name"
node --test quickstart.test.js
echo "--- Cleaning environment for $orch_name ---"
rm -rf "${orch_name}/node_modules"
)
}
cleanup_all() {
echo "--- Final cleanup: Shutting down processes and dropping table ---"
if [ -n "$TOOLBOX_PID" ]; then
kill $TOOLBOX_PID || true
fi
if [ -n "$PROXY_PID" ]; then
kill $PROXY_PID || true
fi
}
trap cleanup_all EXIT
# Main script execution
install_system_packages
start_cloud_sql_proxy
export PGHOST=127.0.0.1
export PGPORT=5432
export PGPASSWORD="$DB_PASSWORD"
export GOOGLE_API_KEY="$GOOGLE_API_KEY"
setup_toolbox
for ORCH_DIR in "$QUICKSTART_JS_DIR"/*/; do
if [ ! -d "$ORCH_DIR" ]; then
continue
fi
run_orch_test "$ORCH_DIR"
done

View File

@@ -1,115 +0,0 @@
# 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.
#!/bin/bash
set -e
TABLE_NAME="hotels_python"
QUICKSTART_PYTHON_DIR="docs/en/getting-started/quickstart/python"
SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql"
PROXY_PID=""
TOOLBOX_PID=""
install_system_packages() {
apt-get update && apt-get install -y \
postgresql-client \
python3-venv \
wget \
gettext-base \
netcat-openbsd
}
start_cloud_sql_proxy() {
wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy
chmod +x /usr/local/bin/cloud-sql-proxy
cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" &
PROXY_PID=$!
for i in {1..30}; do
if nc -z 127.0.0.1 5432; then
echo "Cloud SQL Proxy is up and running."
return
fi
sleep 1
done
echo "Cloud SQL Proxy failed to start within the timeout period."
exit 1
}
setup_toolbox() {
TOOLBOX_YAML="/tools.yaml"
echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML"
if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi
wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox"
chmod +x "/toolbox"
/toolbox --tools-file "$TOOLBOX_YAML" &
TOOLBOX_PID=$!
sleep 2
}
setup_orch_table() {
export TABLE_NAME
envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME"
}
run_orch_test() {
local orch_dir="$1"
local orch_name
orch_name=$(basename "$orch_dir")
(
set -e
setup_orch_table
cd "$orch_dir"
local VENV_DIR=".venv"
python3 -m venv "$VENV_DIR"
source "$VENV_DIR/bin/activate"
pip install -r requirements.txt
echo "--- Running tests for $orch_name ---"
cd ..
ORCH_NAME="$orch_name" pytest
rm -rf "$VENV_DIR"
)
}
cleanup_all() {
echo "--- Final cleanup: Shutting down processes and dropping table ---"
if [ -n "$TOOLBOX_PID" ]; then
kill $TOOLBOX_PID || true
fi
if [ -n "$PROXY_PID" ]; then
kill $PROXY_PID || true
fi
}
trap cleanup_all EXIT
# Main script execution
install_system_packages
start_cloud_sql_proxy
export PGHOST=127.0.0.1
export PGPORT=5432
export PGPASSWORD="$DB_PASSWORD"
export GOOGLE_API_KEY="$GOOGLE_API_KEY"
setup_toolbox
for ORCH_DIR in "$QUICKSTART_PYTHON_DIR"/*/; do
if [ ! -d "$ORCH_DIR" ]; then
continue
fi
run_orch_test "$ORCH_DIR"
done

View File

@@ -23,13 +23,18 @@ steps:
- |
set -ex
export VERSION=$(cat ./cmd/version.txt)
chmod +x .ci/quickstart_test/run_go_tests.sh
.ci/quickstart_test/run_go_tests.sh
chmod +x .ci/sample_tests/run_tests.sh
.ci/sample_tests/run_tests.sh
env:
- 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}'
- 'GCP_PROJECT=${_GCP_PROJECT}'
- 'DATABASE_NAME=${_DATABASE_NAME}'
- 'DB_USER=${_DB_USER}'
- 'TARGET_ROOT=docs/en/getting-started/quickstart/go'
- 'TARGET_LANG=go'
- 'TABLE_NAME=hotels_go'
- 'SQL_FILE=.ci/sample_tests/setup_hotels.sql'
- 'AGENT_FILE_PATTERN=quickstart.go'
secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD']
availableSecrets:

View File

@@ -23,13 +23,18 @@ steps:
- |
set -ex
export VERSION=$(cat ./cmd/version.txt)
chmod +x .ci/quickstart_test/run_js_tests.sh
.ci/quickstart_test/run_js_tests.sh
chmod +x .ci/sample_tests/run_tests.sh
.ci/sample_tests/run_tests.sh
env:
- 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}'
- 'GCP_PROJECT=${_GCP_PROJECT}'
- 'DATABASE_NAME=${_DATABASE_NAME}'
- 'DB_USER=${_DB_USER}'
- 'TARGET_ROOT=docs/en/getting-started/quickstart/js'
- 'TARGET_LANG=js'
- 'TABLE_NAME=hotels_js'
- 'SQL_FILE=.ci/sample_tests/setup_hotels.sql'
- 'AGENT_FILE_PATTERN=quickstart.js'
secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD']
availableSecrets:

View File

@@ -23,13 +23,18 @@ steps:
- |
set -ex
export VERSION=$(cat ./cmd/version.txt)
chmod +x .ci/quickstart_test/run_py_tests.sh
.ci/quickstart_test/run_py_tests.sh
chmod +x .ci/sample_tests/run_tests.sh
.ci/sample_tests/run_tests.sh
env:
- 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}'
- 'GCP_PROJECT=${_GCP_PROJECT}'
- 'DATABASE_NAME=${_DATABASE_NAME}'
- 'DB_USER=${_DB_USER}'
- 'TARGET_ROOT=docs/en/getting-started/quickstart/python'
- 'TARGET_LANG=python'
- 'TABLE_NAME=hotels_python'
- 'SQL_FILE=.ci/sample_tests/setup_hotels.sql'
- 'AGENT_FILE_PATTERN=quickstart.py'
secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD']
availableSecrets:

View File

@@ -0,0 +1,202 @@
# Copyright 2026 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.
set -e
# --- Configuration (from Environment Variables) ---
# TARGET_ROOT: The directory to search for tests (e.g., docs/en/getting-started/quickstart/js)
# TARGET_LANG: python, js, go
# TABLE_NAME: Database table name to use
# SQL_FILE: Path to the SQL setup file
# AGENT_FILE_PATTERN: Filename to look for (e.g., quickstart.js or agent.py)
VERSION=$(cat ./cmd/version.txt)
# Process IDs & Logs
PROXY_PID=""
TOOLBOX_PID=""
PROXY_LOG="cloud_sql_proxy.log"
TOOLBOX_LOG="toolbox_server.log"
install_system_packages() {
echo "Installing system packages..."
apt-get update && apt-get install -y \
postgresql-client \
wget \
gettext-base \
netcat-openbsd
if [[ "$TARGET_LANG" == "python" ]]; then
apt-get install -y python3-venv
fi
}
start_cloud_sql_proxy() {
echo "Starting Cloud SQL Proxy..."
wget -q "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy
chmod +x /usr/local/bin/cloud-sql-proxy
cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" > "$PROXY_LOG" 2>&1 &
PROXY_PID=$!
# Health Check
for i in {1..30}; do
if nc -z 127.0.0.1 5432; then
echo "Cloud SQL Proxy is up and running."
return
fi
sleep 1
done
echo "ERROR: Cloud SQL Proxy failed to start. Logs:"
cat "$PROXY_LOG"
exit 1
}
setup_toolbox() {
echo "Setting up Toolbox server..."
TOOLBOX_YAML="/tools.yaml"
echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML"
wget -q "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox"
chmod +x "/toolbox"
/toolbox --tools-file "$TOOLBOX_YAML" > "$TOOLBOX_LOG" 2>&1 &
TOOLBOX_PID=$!
# Health Check
for i in {1..15}; do
if nc -z 127.0.0.1 5000; then
echo "Toolbox server is up and running."
return
fi
sleep 1
done
echo "ERROR: Toolbox server failed to start. Logs:"
cat "$TOOLBOX_LOG"
exit 1
}
setup_db_table() {
echo "Setting up database table $TABLE_NAME using $SQL_FILE..."
export TABLE_NAME
envsubst < "$SQL_FILE" | psql -h 127.0.0.1 -p 5432 -U "$DB_USER" -d "$DATABASE_NAME"
}
run_python_test() {
local dir=$1
local name=$(basename "$dir")
echo "--- Running Python Test: $name ---"
(
cd "$dir"
python3 -m venv .venv
source .venv/bin/activate
pip install -q -r requirements.txt pytest
cd ..
local test_file=$(find . -maxdepth 1 -name "*test.py" | head -n 1)
if [ -n "$test_file" ]; then
echo "Found native test: $test_file. Running pytest..."
export ORCH_NAME="$name"
export PYTHONPATH="../"
pytest "$test_file"
else
echo "No native test found. running agent directly..."
export PYTHONPATH="../"
python3 "${name}/${AGENT_FILE_PATTERN}"
fi
rm -rf "${name}/.venv"
)
}
run_js_test() {
local dir=$1
local name=$(basename "$dir")
echo "--- Running JS Test: $name ---"
(
cd "$dir"
if [ -f "package-lock.json" ]; then npm ci -q; else npm install -q; fi
cd ..
# Looking for a JS test file in the parent directory
local test_file=$(find . -maxdepth 1 -name "*test.js" | head -n 1)
if [ -n "$test_file" ]; then
echo "Found native test: $test_file. Running node --test..."
export ORCH_NAME="$name"
node --test "$test_file"
else
echo "No native test found. running agent directly..."
node "${name}/${AGENT_FILE_PATTERN}"
fi
rm -rf "${name}/node_modules"
)
}
run_go_test() {
local dir=$1
local name=$(basename "$dir")
if [ "$name" == "openAI" ]; then
echo -e "\nSkipping framework '${name}': Temporarily excluded."
return
fi
echo "--- Running Go Test: $name ---"
(
cd "$dir"
if [ -f "go.mod" ]; then
go mod tidy
fi
cd ..
local test_file=$(find . -maxdepth 1 -name "*test.go" | head -n 1)
if [ -n "$test_file" ]; then
echo "Found native test: $test_file. Running go test..."
export ORCH_NAME="$name"
go test -v ./...
else
echo "No native test found. running agent directly..."
cd "$name"
go run "."
fi
)
}
cleanup() {
echo "Cleaning up background processes..."
[ -n "$TOOLBOX_PID" ] && kill "$TOOLBOX_PID" || true
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" || true
}
trap cleanup EXIT
# --- Execution ---
install_system_packages
start_cloud_sql_proxy
export PGHOST=127.0.0.1
export PGPORT=5432
export PGPASSWORD="$DB_PASSWORD"
export GOOGLE_API_KEY="$GOOGLE_API_KEY"
setup_toolbox
setup_db_table
echo "Scanning $TARGET_ROOT for tests with pattern $AGENT_FILE_PATTERN..."
find "$TARGET_ROOT" -name "$AGENT_FILE_PATTERN" | while read -r agent_file; do
sample_dir=$(dirname "$agent_file")
if [[ "$TARGET_LANG" == "python" ]]; then
run_python_test "$sample_dir"
elif [[ "$TARGET_LANG" == "js" ]]; then
run_js_test "$sample_dir"
elif [[ "$TARGET_LANG" == "go" ]]; then
run_go_test "$sample_dir"
fi
done

View File

@@ -35,6 +35,7 @@ import (
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/auth"
"github.com/googleapis/genai-toolbox/internal/cli/invoke"
"github.com/googleapis/genai-toolbox/internal/cli/skills"
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
"github.com/googleapis/genai-toolbox/internal/log"
"github.com/googleapis/genai-toolbox/internal/prebuiltconfigs"
@@ -228,7 +229,6 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerlistgraphs"
_ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerlisttables"
_ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannersql"
_ "github.com/googleapis/genai-toolbox/internal/tools/spanneradmin/spannercreateinstance"
_ "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqliteexecutesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqlitesql"
_ "github.com/googleapis/genai-toolbox/internal/tools/tidb/tidbexecutesql"
@@ -276,7 +276,6 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/sources/singlestore"
_ "github.com/googleapis/genai-toolbox/internal/sources/snowflake"
_ "github.com/googleapis/genai-toolbox/internal/sources/spanner"
_ "github.com/googleapis/genai-toolbox/internal/sources/spanneradmin"
_ "github.com/googleapis/genai-toolbox/internal/sources/sqlite"
_ "github.com/googleapis/genai-toolbox/internal/sources/tidb"
_ "github.com/googleapis/genai-toolbox/internal/sources/trino"
@@ -403,6 +402,8 @@ func NewCommand(opts ...Option) *Command {
// Register subcommands for tool invocation
baseCmd.AddCommand(invoke.NewCommand(cmd))
// Register subcommands for skill generation
baseCmd.AddCommand(skills.NewCommand(cmd))
return cmd
}

179
cmd/skill_generate_test.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright 2026 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 cmd
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestGenerateSkill(t *testing.T) {
// Create a temporary directory for tests
tmpDir := t.TempDir()
outputDir := filepath.Join(tmpDir, "skills")
// Create a tools.yaml file with a sqlite tool
toolsFileContent := `
sources:
my-sqlite:
kind: sqlite
database: test.db
tools:
hello-sqlite:
kind: sqlite-sql
source: my-sqlite
description: "hello tool"
statement: "SELECT 'hello' as greeting"
`
toolsFilePath := filepath.Join(tmpDir, "tools.yaml")
if err := os.WriteFile(toolsFilePath, []byte(toolsFileContent), 0644); err != nil {
t.Fatalf("failed to write tools file: %v", err)
}
args := []string{
"skills-generate",
"--tools-file", toolsFilePath,
"--output-dir", outputDir,
"--name", "hello-sqlite",
"--description", "hello tool",
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, got, err := invokeCommandWithContext(ctx, args)
if err != nil {
t.Fatalf("command failed: %v\nOutput: %s", err, got)
}
// Verify generated directory structure
skillPath := filepath.Join(outputDir, "hello-sqlite")
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
t.Fatalf("skill directory not created: %s", skillPath)
}
// Check SKILL.md
skillMarkdown := filepath.Join(skillPath, "SKILL.md")
content, err := os.ReadFile(skillMarkdown)
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
expectedFrontmatter := `---
name: hello-sqlite
description: hello tool
---`
if !strings.HasPrefix(string(content), expectedFrontmatter) {
t.Errorf("SKILL.md does not have expected frontmatter format.\nExpected prefix:\n%s\nGot:\n%s", expectedFrontmatter, string(content))
}
if !strings.Contains(string(content), "## Usage") {
t.Errorf("SKILL.md does not contain '## Usage' section")
}
if !strings.Contains(string(content), "## Scripts") {
t.Errorf("SKILL.md does not contain '## Scripts' section")
}
if !strings.Contains(string(content), "### hello-sqlite") {
t.Errorf("SKILL.md does not contain '### hello-sqlite' tool header")
}
// Check script file
scriptFilename := "hello-sqlite.js"
scriptPath := filepath.Join(skillPath, "scripts", scriptFilename)
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
t.Fatalf("script file not created: %s", scriptPath)
}
scriptContent, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("failed to read script file: %v", err)
}
if !strings.Contains(string(scriptContent), "hello-sqlite") {
t.Errorf("script file does not contain expected tool name")
}
// Check assets
assetPath := filepath.Join(skillPath, "assets", "hello-sqlite.yaml")
if _, err := os.Stat(assetPath); os.IsNotExist(err) {
t.Fatalf("asset file not created: %s", assetPath)
}
assetContent, err := os.ReadFile(assetPath)
if err != nil {
t.Fatalf("failed to read asset file: %v", err)
}
if !strings.Contains(string(assetContent), "hello-sqlite") {
t.Errorf("asset file does not contain expected tool name")
}
}
func TestGenerateSkill_NoConfig(t *testing.T) {
tmpDir := t.TempDir()
outputDir := filepath.Join(tmpDir, "skills")
args := []string{
"skills-generate",
"--output-dir", outputDir,
"--name", "test",
"--description", "test",
}
_, _, err := invokeCommandWithContext(context.Background(), args)
if err == nil {
t.Fatal("expected command to fail when no configuration is provided and tools.yaml is missing")
}
// Should not have created the directory if no config was processed
if _, err := os.Stat(outputDir); !os.IsNotExist(err) {
t.Errorf("output directory should not have been created")
}
}
func TestGenerateSkill_MissingArguments(t *testing.T) {
tmpDir := t.TempDir()
toolsFilePath := filepath.Join(tmpDir, "tools.yaml")
if err := os.WriteFile(toolsFilePath, []byte("tools: {}"), 0644); err != nil {
t.Fatalf("failed to write tools file: %v", err)
}
tests := []struct {
name string
args []string
}{
{
name: "missing name",
args: []string{"skills-generate", "--tools-file", toolsFilePath, "--description", "test"},
},
{
name: "missing description",
args: []string{"skills-generate", "--tools-file", toolsFilePath, "--name", "test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, got, err := invokeCommandWithContext(context.Background(), tt.args)
if err == nil {
t.Fatalf("expected command to fail due to missing arguments, but it succeeded\nOutput: %s", got)
}
})
}
}

View File

@@ -1,59 +0,0 @@
# Cloud Spanner Admin MCP Server
The Cloud Spanner Admin Model Context Protocol (MCP) Server gives AI-powered development tools the ability to manage your Google Cloud Spanner infrastructure. It supports creating instances.
## Features
An editor configured to use the Cloud Spanner Admin MCP server can use its AI capabilities to help you:
- **Provision & Manage Infrastructure** - Create Cloud Spanner instances
## Prerequisites
* [Node.js](https://nodejs.org/) installed.
* A Google Cloud project with the **Cloud Spanner Admin API** enabled.
* Ensure [Application Default Credentials](https://cloud.google.com/docs/authentication/gcloud) are available in your environment.
* IAM Permissions:
* Cloud Spanner Admin (`roles/spanner.admin`)
## Install & Configuration
In the Antigravity MCP Store, click the "Install" button.
You'll now be able to see all enabled tools in the "Tools" tab.
> [!NOTE]
> If you encounter issues with Windows Defender blocking the execution, you may need to configure an allowlist. See [Configure exclusions for Microsoft Defender Antivirus](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/configure-exclusions-microsoft-defender-antivirus?view=o365-worldwide) for more details.
## Usage
Once configured, the MCP server will automatically provide Cloud Spanner Admin capabilities to your AI assistant. You can:
* "Create a new Spanner instance named 'my-spanner-instance' in the 'my-gcp-project' project with config 'regional-us-central1', edition 'ENTERPRISE', and 1 node."
## Server Capabilities
The Cloud Spanner Admin MCP server provides the following tools:
| Tool Name | Description |
|:------------------|:---------------------------------|
| `create_instance` | Create a Cloud Spanner instance. |
## Custom MCP Server Configuration
Add the following configuration to your MCP client (e.g., `settings.json` for Gemini CLI, `mcp_config.json` for Antigravity):
```json
{
"mcpServers": {
"spanner-admin": {
"command": "npx",
"args": ["-y", "@toolbox-sdk/server", "--prebuilt", "spanner-admin", "--stdio"]
}
}
}
```
## Documentation
For more information, visit the [Cloud Spanner Admin API documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1).

View File

@@ -0,0 +1,112 @@
---
title: "Generate Agent Skills"
type: docs
weight: 10
description: >
How to generate agent skills from a toolset.
---
The `skills-generate` command allows you to convert a **toolset** into an **Agent Skill**. A toolset is a collection of tools, and the generated skill will contain metadata and execution scripts for all tools within that toolset, complying with the [Agent Skill specification](https://agentskills.io/specification).
## Before you begin
1. Make sure you have the `toolbox` executable in your PATH.
2. Make sure you have [Node.js](https://nodejs.org/) installed on your system.
## Generating a Skill from a Toolset
A skill package consists of a `SKILL.md` file (with required YAML frontmatter) and a set of Node.js scripts. Each tool defined in your toolset maps to a corresponding script in the generated Node.js scripts (`.js`) that work across different platforms (Linux, macOS, Windows).
### Command Usage
The basic syntax for the command is:
```bash
toolbox <tool-source> skills-generate \
--name <skill-name> \
--toolset <toolset-name> \
--description <description> \
--output-dir <output-directory>
```
- `<tool-source>`: Can be `--tools-file`, `--tools-files`, `--tools-folder`, and `--prebuilt`. See the [CLI Reference](../reference/cli.md) for details.
- `--name`: Name of the generated skill.
- `--description`: Description of the generated skill.
- `--toolset`: (Optional) Name of the toolset to convert into a skill. If not provided, all tools will be included.
- `--output-dir`: (Optional) Directory to output generated skills (default: "skills").
{{< notice note >}}
**Note:** The `<skill-name>` must follow the Agent Skill [naming convention](https://agentskills.io/specification): it must contain only lowercase alphanumeric characters and hyphens, cannot start or end with a hyphen, and cannot contain consecutive hyphens (e.g., `my-skill`, `data-processing`).
{{< /notice >}}
### Example: Custom Tools File
1. Create a `tools.yaml` file with a toolset and some tools:
```yaml
tools:
tool_a:
description: "First tool"
run:
command: "echo 'Tool A'"
tool_b:
description: "Second tool"
run:
command: "echo 'Tool B'"
toolsets:
my_toolset:
tools:
- tool_a
- tool_b
```
2. Generate the skill:
```bash
toolbox --tools-file tools.yaml skills-generate \
--name "my-skill" \
--toolset "my_toolset" \
--description "A skill containing multiple tools" \
--output-dir "generated-skills"
```
3. The generated skill directory structure:
```text
generated-skills/
└── my-skill/
├── SKILL.md
├── assets/
│ ├── tool_a.yaml
│ └── tool_b.yaml
└── scripts/
├── tool_a.js
└── tool_b.js
```
In this example, the skill contains two Node.js scripts (`tool_a.js` and `tool_b.js`), each mapping to a tool in the original toolset.
### Example: Prebuilt Configuration
You can also generate skills from prebuilt toolsets:
```bash
toolbox --prebuilt alloydb-postgres-admin skills-generate \
--name "alloydb-postgres-admin" \
--description "skill for performing administrative operations on alloydb"
```
## Installing the Generated Skill in Gemini CLI
Once you have generated a skill, you can install it into the Gemini CLI using the `gemini skills install` command.
### Installation Command
Provide the path to the directory containing the generated skill:
```bash
gemini skills install /path/to/generated-skills/my-skill
```
Alternatively, use ~/.gemini/skills as the `--output-dir` to generate the skill straight to the Gemini CLI.

View File

@@ -20,14 +20,15 @@ The `invoke` command allows you to invoke tools defined in your configuration di
1. Make sure you have the `toolbox` binary installed or built.
2. Make sure you have a valid tool configuration file (e.g., `tools.yaml`).
## Basic Usage
### Command Usage
The basic syntax for the command is:
```bash
toolbox [--tools-file <path> | --prebuilt <name>] invoke <tool-name> [params]
toolbox <tool-source> invoke <tool-name> [params]
```
- `<tool-source>`: Can be `--tools-file`, `--tools-files`, `--tools-folder`, and `--prebuilt`. See the [CLI Reference](../reference/cli.md) for details.
- `<tool-name>`: The name of the tool you want to call. This must match the name defined in your `tools.yaml`.
- `[params]`: (Optional) A JSON string representing the arguments for the tool.

View File

@@ -32,7 +32,8 @@ description: >
## Sub Commands
### `invoke`
<details>
<summary><code>invoke</code></summary>
Executes a tool directly with the provided parameters. This is useful for testing tool configurations and parameters without needing a full client setup.
@@ -42,8 +43,36 @@ Executes a tool directly with the provided parameters. This is useful for testin
toolbox invoke <tool-name> [params]
```
- `<tool-name>`: The name of the tool to execute (as defined in your configuration).
- `[params]`: (Optional) A JSON string containing the parameters for the tool.
**Arguments:**
- `tool-name`: The name of the tool to execute (as defined in your configuration).
- `params`: (Optional) A JSON string containing the parameters for the tool.
For more detailed instructions, see [Invoke Tools via CLI](../how-to/invoke_tool.md).
</details>
<details>
<summary><code>skills-generate</code></summary>
Generates a skill package from a specified toolset. Each tool in the toolset will have a corresponding Node.js execution script in the generated skill.
**Syntax:**
```bash
toolbox skills-generate --name <name> --description <description> --toolset <toolset> --output-dir <output>
```
**Flags:**
- `--name`: Name of the generated skill.
- `--description`: Description of the generated skill.
- `--toolset`: (Optional) Name of the toolset to convert into a skill. If not provided, all tools will be included.
- `--output-dir`: (Optional) Directory to output generated skills (default: "skills").
For more detailed instructions, see [Generate Agent Skills](../how-to/generate_skill.md).
</details>
## Examples

View File

@@ -1,43 +0,0 @@
---
title: Spanner Admin
type: docs
weight: 1
description: "A \"spanner-admin\" source provides a client for the Cloud Spanner Admin API.\n"
alias: [/resources/sources/spanner-admin]
---
## About
The `spanner-admin` source provides a client to interact with the [Google
Cloud Spanner Admin API](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1). This
allows tools to perform administrative tasks on Spanner instances, such as
creating instances.
Authentication can be handled in two ways:
1. **Application Default Credentials (ADC):** By default, the source uses ADC
to authenticate with the API.
2. **Client-side OAuth:** If `useClientOAuth` is set to `true`, the source will
expect an OAuth 2.0 access token to be provided by the client (e.g., a web
browser) for each request.
## Example
```yaml
kind: sources
name: my-spanner-admin
type: spanner-admin
---
kind: sources
name: my-oauth-spanner-admin
type: spanner-admin
useClientOAuth: true
```
## Reference
| **field** | **type** | **required** | **description** |
| -------------- | :------: | :----------: | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| type | string | true | Must be "spanner-admin". |
| defaultProject | string | false | The Google Cloud project ID to use for Spanner infrastructure tools. |
| useClientOAuth | boolean | false | If true, the source will use client-side OAuth for authorization. Otherwise, it will use Application Default Credentials. Defaults to `false`. |

View File

@@ -1,52 +0,0 @@
---
title: spanner-create-instance
type: docs
weight: 2
description: "Create a Cloud Spanner instance."
---
The `spanner-create-instance` tool creates a new Cloud Spanner instance in a
specified Google Cloud project.
{{< notice info >}}
This tool uses the `spanner-admin` source.
{{< /notice >}}
## Configuration
Here is an example of how to configure the `spanner-create-instance` tool in
your `tools.yaml` file:
```yaml
kind: sources
name: my-spanner-admin-source
type: spanner-admin
---
kind: tools
name: create_my_spanner_instance
type: spanner-create-instance
source: my-spanner-admin-source
description: "Creates a Spanner instance."
```
## Parameters
The `spanner-create-instance` tool has the following parameters:
| **field** | **type** | **required** | **description** |
| --------------- | :------: | :----------: | ------------------------------------------------------------------------------------ |
| project | string | true | The Google Cloud project ID. |
| instanceId | string | true | The ID of the instance to create. |
| displayName | string | true | The display name of the instance. |
| config | string | true | The instance configuration (e.g., `regional-us-central1`). |
| nodeCount | integer | true | The number of nodes. Mutually exclusive with `processingUnits` (one must be 0). |
| processingUnits | integer | true | The number of processing units. Mutually exclusive with `nodeCount` (one must be 0). |
| edition | string | false | The edition of the instance (`STANDARD`, `ENTERPRISE`, `ENTERPRISE_PLUS`). |
## Reference
| **field** | **type** | **required** | **description** |
| ----------- | :------: | :----------: | ------------------------------------------------------------ |
| type | string | true | Must be `spanner-create-instance`. |
| source | string | true | The name of the `spanner-admin` source to use for this tool. |
| description | string | false | A description of the tool that is passed to the agent. |

View File

@@ -0,0 +1,237 @@
// Copyright 2026 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 skills
import (
"context"
_ "embed"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/googleapis/genai-toolbox/internal/log"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/server/resources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/spf13/cobra"
)
// RootCommand defines the interface for required by skills-generate subcommand.
// This allows subcommands to access shared resources and functionality without
// direct coupling to the root command's implementation.
type RootCommand interface {
// Config returns a copy of the current server configuration.
Config() server.ServerConfig
// LoadConfig loads and merges the configuration from files, folders, and prebuilts.
LoadConfig(ctx context.Context) error
// Setup initializes the runtime environment, including logging and telemetry.
// It returns the updated context and a shutdown function to be called when finished.
Setup(ctx context.Context) (context.Context, func(context.Context) error, error)
// Logger returns the logger instance.
Logger() log.Logger
}
// Command is the command for generating skills.
type Command struct {
*cobra.Command
rootCmd RootCommand
name string
description string
toolset string
outputDir string
}
// NewCommand creates a new Command.
func NewCommand(rootCmd RootCommand) *cobra.Command {
cmd := &Command{
rootCmd: rootCmd,
}
cmd.Command = &cobra.Command{
Use: "skills-generate",
Short: "Generate skills from tool configurations",
RunE: func(c *cobra.Command, args []string) error {
return cmd.run(c)
},
}
cmd.Flags().StringVar(&cmd.name, "name", "", "Name of the generated skill.")
cmd.Flags().StringVar(&cmd.description, "description", "", "Description of the generated skill")
cmd.Flags().StringVar(&cmd.toolset, "toolset", "", "Name of the toolset to convert into a skill. If not provided, all tools will be included.")
cmd.Flags().StringVar(&cmd.outputDir, "output-dir", "skills", "Directory to output generated skills")
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("description")
return cmd.Command
}
func (c *Command) run(cmd *cobra.Command) error {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
ctx, shutdown, err := c.rootCmd.Setup(ctx)
if err != nil {
return err
}
defer func() {
_ = shutdown(ctx)
}()
logger := c.rootCmd.Logger()
// Load and merge tool configurations
if err := c.rootCmd.LoadConfig(ctx); err != nil {
return err
}
if err := os.MkdirAll(c.outputDir, 0755); err != nil {
errMsg := fmt.Errorf("error creating output directory: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
logger.InfoContext(ctx, fmt.Sprintf("Generating skill '%s'...", c.name))
// Initialize toolbox and collect tools
allTools, err := c.collectTools(ctx)
if err != nil {
errMsg := fmt.Errorf("error collecting tools: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
if len(allTools) == 0 {
logger.InfoContext(ctx, "No tools found to generate.")
return nil
}
// Generate the combined skill directory
skillPath := filepath.Join(c.outputDir, c.name)
if err := os.MkdirAll(skillPath, 0755); err != nil {
errMsg := fmt.Errorf("error creating skill directory: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
// Generate assets directory
assetsPath := filepath.Join(skillPath, "assets")
if err := os.MkdirAll(assetsPath, 0755); err != nil {
errMsg := fmt.Errorf("error creating assets dir: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
// Generate scripts directory
scriptsPath := filepath.Join(skillPath, "scripts")
if err := os.MkdirAll(scriptsPath, 0755); err != nil {
errMsg := fmt.Errorf("error creating scripts dir: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
// Iterate over keys to ensure deterministic order
var toolNames []string
for name := range allTools {
toolNames = append(toolNames, name)
}
sort.Strings(toolNames)
for _, toolName := range toolNames {
// Generate YAML config in asset directory
minimizedContent, err := generateToolConfigYAML(c.rootCmd.Config(), toolName)
if err != nil {
errMsg := fmt.Errorf("error generating filtered config for %s: %w", toolName, err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
specificToolsFileName := fmt.Sprintf("%s.yaml", toolName)
if minimizedContent != nil {
destPath := filepath.Join(assetsPath, specificToolsFileName)
if err := os.WriteFile(destPath, minimizedContent, 0644); err != nil {
errMsg := fmt.Errorf("error writing filtered config for %s: %w", toolName, err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
}
// Generate wrapper script in scripts directory
scriptContent, err := generateScriptContent(toolName, specificToolsFileName)
if err != nil {
errMsg := fmt.Errorf("error generating script content for %s: %w", toolName, err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
scriptFilename := filepath.Join(scriptsPath, fmt.Sprintf("%s.js", toolName))
if err := os.WriteFile(scriptFilename, []byte(scriptContent), 0755); err != nil {
errMsg := fmt.Errorf("error writing script %s: %w", scriptFilename, err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
}
// Generate SKILL.md
skillContent, err := generateSkillMarkdown(c.name, c.description, allTools)
if err != nil {
errMsg := fmt.Errorf("error generating SKILL.md content: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
skillMdPath := filepath.Join(skillPath, "SKILL.md")
if err := os.WriteFile(skillMdPath, []byte(skillContent), 0644); err != nil {
errMsg := fmt.Errorf("error writing SKILL.md: %w", err)
logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
logger.InfoContext(ctx, fmt.Sprintf("Successfully generated skill '%s' with %d tools.", c.name, len(allTools)))
return nil
}
func (c *Command) collectTools(ctx context.Context) (map[string]tools.Tool, error) {
// Initialize Resources
sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, c.rootCmd.Config())
if err != nil {
return nil, fmt.Errorf("failed to initialize resources: %w", err)
}
resourceMgr := resources.NewResourceManager(sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap)
result := make(map[string]tools.Tool)
if c.toolset == "" {
return toolsMap, nil
}
ts, ok := resourceMgr.GetToolset(c.toolset)
if !ok {
return nil, fmt.Errorf("toolset %q not found", c.toolset)
}
for _, t := range ts.Tools {
if t != nil {
tool := *t
result[tool.McpManifest().Name] = tool
}
}
return result, nil
}

View File

@@ -0,0 +1,296 @@
// Copyright 2026 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 skills
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"text/template"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
)
const skillTemplate = `---
name: {{.SkillName}}
description: {{.SkillDescription}}
---
## Usage
All scripts can be executed using Node.js. Replace ` + "`" + `<param_name>` + "`" + ` and ` + "`" + `<param_value>` + "`" + ` with actual values.
**Bash:**
` + "`" + `node scripts/<script_name>.js '{"<param_name>": "<param_value>"}'` + "`" + `
**PowerShell:**
` + "`" + `node scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'` + "`" + `
## Scripts
{{range .Tools}}
### {{.Name}}
{{.Description}}
{{.ParametersSchema}}
---
{{end}}
`
type toolTemplateData struct {
Name string
Description string
ParametersSchema string
}
type skillTemplateData struct {
SkillName string
SkillDescription string
Tools []toolTemplateData
}
// generateSkillMarkdown generates the content of the SKILL.md file.
// It includes usage instructions and a reference section for each tool in the skill,
// detailing its description and parameters.
func generateSkillMarkdown(skillName, skillDescription string, toolsMap map[string]tools.Tool) (string, error) {
var toolsData []toolTemplateData
// Order tools based on name
var toolNames []string
for name := range toolsMap {
toolNames = append(toolNames, name)
}
sort.Strings(toolNames)
for _, name := range toolNames {
tool := toolsMap[name]
manifest := tool.Manifest()
parametersSchema, err := formatParameters(manifest.Parameters)
if err != nil {
return "", err
}
toolsData = append(toolsData, toolTemplateData{
Name: name,
Description: manifest.Description,
ParametersSchema: parametersSchema,
})
}
data := skillTemplateData{
SkillName: skillName,
SkillDescription: skillDescription,
Tools: toolsData,
}
tmpl, err := template.New("markdown").Parse(skillTemplate)
if err != nil {
return "", fmt.Errorf("error parsing markdown template: %w", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("error executing markdown template: %w", err)
}
return buf.String(), nil
}
const nodeScriptTemplate = `#!/usr/bin/env node
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const toolName = "{{.Name}}";
const toolsFileName = "{{.ToolsFileName}}";
function getToolboxPath() {
try {
const checkCommand = process.platform === 'win32' ? 'where toolbox' : 'which toolbox';
const globalPath = execSync(checkCommand, { stdio: 'pipe', encoding: 'utf-8' }).trim();
if (globalPath) {
return globalPath.split('\n')[0].trim();
}
} catch (e) {
// Ignore error;
}
const localPath = path.resolve(__dirname, '../../../toolbox');
if (fs.existsSync(localPath)) {
return localPath;
}
throw new Error("Toolbox binary not found");
}
let toolboxBinary;
try {
toolboxBinary = getToolboxPath();
} catch (err) {
console.error("Error:", err.message);
process.exit(1);
}
let configArgs = [];
if (toolsFileName) {
configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName));
}
const args = process.argv.slice(2);
const toolboxArgs = [...configArgs, "invoke", toolName, ...args];
const child = spawn(toolboxBinary, toolboxArgs, { stdio: 'inherit' });
child.on('close', (code) => {
process.exit(code);
});
child.on('error', (err) => {
console.error("Error executing toolbox:", err);
process.exit(1);
});
`
type scriptData struct {
Name string
ToolsFileName string
}
// generateScriptContent creates the content for a Node.js wrapper script.
// This script invokes the toolbox CLI with the appropriate configuration
// (using a generated tools file) and arguments to execute the specific tool.
func generateScriptContent(name string, toolsFileName string) (string, error) {
data := scriptData{
Name: name,
ToolsFileName: toolsFileName,
}
tmpl, err := template.New("script").Parse(nodeScriptTemplate)
if err != nil {
return "", fmt.Errorf("error parsing script template: %w", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("error executing script template: %w", err)
}
return buf.String(), nil
}
// formatParameters converts a list of parameter manifests into a formatted JSON schema string.
// This schema is used in the skill documentation to describe the input parameters for a tool.
func formatParameters(params []parameters.ParameterManifest) (string, error) {
if len(params) == 0 {
return "", nil
}
properties := make(map[string]interface{})
var required []string
for _, p := range params {
paramMap := map[string]interface{}{
"type": p.Type,
"description": p.Description,
}
if p.Default != nil {
paramMap["default"] = p.Default
}
properties[p.Name] = paramMap
if p.Required {
required = append(required, p.Name)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
schemaJSON, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return "", fmt.Errorf("error generating parameters schema: %w", err)
}
return fmt.Sprintf("#### Parameters\n\n```json\n%s\n```", string(schemaJSON)), nil
}
// generateToolConfigYAML generates the YAML configuration for a single tool and its dependency (source).
// It extracts the relevant tool and source configurations from the server config and formats them
// into a YAML document suitable for inclusion in the skill's assets.
func generateToolConfigYAML(cfg server.ServerConfig, toolName string) ([]byte, error) {
toolCfg, ok := cfg.ToolConfigs[toolName]
if !ok {
return nil, fmt.Errorf("error finding tool config: %s", toolName)
}
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
// Process Tool Config
toolWrapper := struct {
Kind string `yaml:"kind"`
Config tools.ToolConfig `yaml:",inline"`
}{
Kind: "tools",
Config: toolCfg,
}
if err := encoder.Encode(toolWrapper); err != nil {
return nil, fmt.Errorf("error encoding tool config: %w", err)
}
// Process Source Config
var toolMap map[string]interface{}
b, err := yaml.Marshal(toolCfg)
if err != nil {
return nil, fmt.Errorf("error marshaling tool config: %w", err)
}
if err := yaml.Unmarshal(b, &toolMap); err != nil {
return nil, fmt.Errorf("error unmarshaling tool config map: %w", err)
}
if sourceName, ok := toolMap["source"].(string); ok && sourceName != "" {
sourceCfg, ok := cfg.SourceConfigs[sourceName]
if !ok {
return nil, fmt.Errorf("error finding source config: %s", sourceName)
}
sourceWrapper := struct {
Kind string `yaml:"kind"`
Config sources.SourceConfig `yaml:",inline"`
}{
Kind: "sources",
Config: sourceCfg,
}
if err := encoder.Encode(sourceWrapper); err != nil {
return nil, fmt.Errorf("error encoding source config: %w", err)
}
}
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,347 @@
// Copyright 2026 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 skills
import (
"context"
"strings"
"testing"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
"go.opentelemetry.io/otel/trace"
)
type MockToolConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Source string `yaml:"source"`
Other string `yaml:"other"`
Parameters parameters.Parameters `yaml:"parameters"`
}
func (m MockToolConfig) ToolConfigType() string {
return m.Type
}
func (m MockToolConfig) Initialize(map[string]sources.Source) (tools.Tool, error) {
return nil, nil
}
type MockSourceConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
ConnectionString string `yaml:"connection_string"`
}
func (m MockSourceConfig) SourceConfigType() string {
return m.Type
}
func (m MockSourceConfig) Initialize(context.Context, trace.Tracer) (sources.Source, error) {
return nil, nil
}
func TestFormatParameters(t *testing.T) {
tests := []struct {
name string
params []parameters.ParameterManifest
wantContains []string
wantErr bool
}{
{
name: "empty parameters",
params: []parameters.ParameterManifest{},
wantContains: []string{""},
},
{
name: "single required string parameter",
params: []parameters.ParameterManifest{
{
Name: "param1",
Description: "A test parameter",
Type: "string",
Required: true,
},
},
wantContains: []string{
"## Parameters",
"```json",
`"type": "object"`,
`"properties": {`,
`"param1": {`,
`"type": "string"`,
`"description": "A test parameter"`,
`"required": [`,
`"param1"`,
},
},
{
name: "mixed parameters with defaults",
params: []parameters.ParameterManifest{
{
Name: "param1",
Description: "Param 1",
Type: "string",
Required: true,
},
{
Name: "param2",
Description: "Param 2",
Type: "integer",
Default: 42,
Required: false,
},
},
wantContains: []string{
`"param1": {`,
`"param2": {`,
`"default": 42`,
`"required": [`,
`"param1"`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := formatParameters(tt.params)
if (err != nil) != tt.wantErr {
t.Errorf("formatParameters() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if len(tt.params) == 0 {
if got != "" {
t.Errorf("formatParameters() = %v, want empty string", got)
}
return
}
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("formatParameters() result missing expected string: %s\nGot:\n%s", want, got)
}
}
})
}
}
func TestGenerateSkillMarkdown(t *testing.T) {
toolsMap := map[string]tools.Tool{
"tool1": server.MockTool{
Description: "First tool",
Params: []parameters.Parameter{
parameters.NewStringParameter("p1", "d1"),
},
},
}
got, err := generateSkillMarkdown("MySkill", "My Description", toolsMap)
if err != nil {
t.Fatalf("generateSkillMarkdown() error = %v", err)
}
expectedSubstrings := []string{
"name: MySkill",
"description: My Description",
"## Usage",
"All scripts can be executed using Node.js",
"**Bash:**",
"`node scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'`",
"**PowerShell:**",
"`node scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'`",
"## Scripts",
"### tool1",
"First tool",
"## Parameters",
}
for _, s := range expectedSubstrings {
if !strings.Contains(got, s) {
t.Errorf("generateSkillMarkdown() missing substring %q", s)
}
}
}
func TestGenerateScriptContent(t *testing.T) {
tests := []struct {
name string
toolName string
toolsFileName string
wantContains []string
}{
{
name: "basic script",
toolName: "test-tool",
toolsFileName: "",
wantContains: []string{
`const toolName = "test-tool";`,
`const toolsFileName = "";`,
`const toolboxArgs = [...configArgs, "invoke", toolName, ...args];`,
},
},
{
name: "script with tools file",
toolName: "complex-tool",
toolsFileName: "tools.yaml",
wantContains: []string{
`const toolName = "complex-tool";`,
`const toolsFileName = "tools.yaml";`,
`configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName));`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := generateScriptContent(tt.toolName, tt.toolsFileName)
if err != nil {
t.Fatalf("generateScriptContent() error = %v", err)
}
for _, s := range tt.wantContains {
if !strings.Contains(got, s) {
t.Errorf("generateScriptContent() missing substring %q\nGot:\n%s", s, got)
}
}
})
}
}
func TestGenerateToolConfigYAML(t *testing.T) {
cfg := server.ServerConfig{
ToolConfigs: server.ToolConfigs{
"tool1": MockToolConfig{
Name: "tool1",
Type: "custom-tool",
Source: "src1",
Other: "foo",
},
"toolNoSource": MockToolConfig{
Name: "toolNoSource",
Type: "http",
},
"toolWithParams": MockToolConfig{
Name: "toolWithParams",
Type: "custom-tool",
Parameters: []parameters.Parameter{
parameters.NewStringParameter("param1", "desc1"),
},
},
"toolWithMissingSource": MockToolConfig{
Name: "toolWithMissingSource",
Type: "custom-tool",
Source: "missing-src",
},
},
SourceConfigs: server.SourceConfigs{
"src1": MockSourceConfig{
Name: "src1",
Type: "postgres",
ConnectionString: "conn1",
},
},
}
tests := []struct {
name string
toolName string
wantContains []string
wantErr bool
wantNil bool
}{
{
name: "tool with source",
toolName: "tool1",
wantContains: []string{
"kind: tools",
"name: tool1",
"type: custom-tool",
"source: src1",
"other: foo",
"---",
"kind: sources",
"name: src1",
"type: postgres",
"connection_string: conn1",
},
},
{
name: "tool without source",
toolName: "toolNoSource",
wantContains: []string{
"kind: tools",
"name: toolNoSource",
"type: http",
},
},
{
name: "tool with parameters",
toolName: "toolWithParams",
wantContains: []string{
"kind: tools",
"name: toolWithParams",
"type: custom-tool",
"parameters:",
"- name: param1",
"type: string",
"description: desc1",
},
},
{
name: "non-existent tool",
toolName: "missing-tool",
wantErr: true,
},
{
name: "tool with missing source config",
toolName: "toolWithMissingSource",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotBytes, err := generateToolConfigYAML(cfg, tt.toolName)
if (err != nil) != tt.wantErr {
t.Errorf("generateToolConfigYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if tt.wantNil {
if gotBytes != nil {
t.Errorf("generateToolConfigYAML() expected nil, got %s", string(gotBytes))
}
return
}
got := string(gotBytes)
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("generateToolConfigYAML() result missing expected string: %q\nGot:\n%s", want, got)
}
}
})
}
}

View File

@@ -51,7 +51,6 @@ var expectedToolSources = []string{
"serverless-spark",
"singlestore",
"snowflake",
"spanner-admin",
"spanner-postgres",
"spanner",
"sqlite",

View File

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

View File

@@ -24,7 +24,6 @@ import (
"testing"
"github.com/go-chi/chi/v5"
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
"github.com/googleapis/genai-toolbox/internal/log"
"github.com/googleapis/genai-toolbox/internal/prompts"
"github.com/googleapis/genai-toolbox/internal/server/resources"
@@ -41,140 +40,6 @@ var (
_ prompts.Prompt = MockPrompt{}
)
// MockTool is used to mock tools in tests
type MockTool struct {
Name string
Description string
Params []parameters.Parameter
manifest tools.Manifest
unauthorized bool
requiresClientAuthrorization bool
}
func (t MockTool) Invoke(context.Context, tools.SourceProvider, parameters.ParamValues, tools.AccessToken) (any, error) {
mock := []any{t.Name}
return mock, nil
}
func (t MockTool) ToConfig() tools.ToolConfig {
return nil
}
// claims is a map of user info decoded from an auth token
func (t MockTool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) {
return parameters.ParseParams(t.Params, data, claimsMap)
}
func (t MockTool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) {
return parameters.EmbedParams(ctx, t.Params, paramValues, embeddingModelsMap, nil)
}
func (t MockTool) Manifest() tools.Manifest {
pMs := make([]parameters.ParameterManifest, 0, len(t.Params))
for _, p := range t.Params {
pMs = append(pMs, p.Manifest())
}
return tools.Manifest{Description: t.Description, Parameters: pMs}
}
func (t MockTool) Authorized(verifiedAuthServices []string) bool {
// defaulted to true
return !t.unauthorized
}
func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) {
// defaulted to false
return t.requiresClientAuthrorization, nil
}
func (t MockTool) GetParameters() parameters.Parameters {
return t.Params
}
func (t MockTool) McpManifest() tools.McpManifest {
properties := make(map[string]parameters.ParameterMcpManifest)
required := make([]string, 0)
authParams := make(map[string][]string)
for _, p := range t.Params {
name := p.GetName()
paramManifest, authParamList := p.McpManifest()
properties[name] = paramManifest
required = append(required, name)
if len(authParamList) > 0 {
authParams[name] = authParamList
}
}
toolsSchema := parameters.McpToolsSchema{
Type: "object",
Properties: properties,
Required: required,
}
mcpManifest := tools.McpManifest{
Name: t.Name,
Description: t.Description,
InputSchema: toolsSchema,
}
if len(authParams) > 0 {
mcpManifest.Metadata = map[string]any{
"toolbox/authParams": authParams,
}
}
return mcpManifest
}
func (t MockTool) GetAuthTokenHeaderName(tools.SourceProvider) (string, error) {
return "Authorization", nil
}
// MockPrompt is used to mock prompts in tests
type MockPrompt struct {
Name string
Description string
Args prompts.Arguments
}
func (p MockPrompt) SubstituteParams(vals parameters.ParamValues) (any, error) {
return []prompts.Message{
{
Role: "user",
Content: fmt.Sprintf("substituted %s", p.Name),
},
}, nil
}
func (p MockPrompt) ParseArgs(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) {
var params parameters.Parameters
for _, arg := range p.Args {
params = append(params, arg.Parameter)
}
return parameters.ParseParams(params, data, claimsMap)
}
func (p MockPrompt) Manifest() prompts.Manifest {
var argManifests []parameters.ParameterManifest
for _, arg := range p.Args {
argManifests = append(argManifests, arg.Manifest())
}
return prompts.Manifest{
Description: p.Description,
Arguments: argManifests,
}
}
func (p MockPrompt) McpManifest() prompts.McpManifest {
return prompts.GetMcpManifest(p.Name, p.Description, p.Args)
}
func (p MockPrompt) ToConfig() prompts.PromptConfig {
return nil
}
var tool1 = MockTool{
Name: "no_params",
Params: []parameters.Parameter{},

159
internal/server/mocks.go Normal file
View File

@@ -0,0 +1,159 @@
// Copyright 2026 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 server
import (
"context"
"fmt"
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
"github.com/googleapis/genai-toolbox/internal/prompts"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
)
// MockTool is used to mock tools in tests
type MockTool struct {
Name string
Description string
Params []parameters.Parameter
manifest tools.Manifest
unauthorized bool
requiresClientAuthrorization bool
}
func (t MockTool) Invoke(context.Context, tools.SourceProvider, parameters.ParamValues, tools.AccessToken) (any, error) {
mock := []any{t.Name}
return mock, nil
}
func (t MockTool) ToConfig() tools.ToolConfig {
return nil
}
// claims is a map of user info decoded from an auth token
func (t MockTool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) {
return parameters.ParseParams(t.Params, data, claimsMap)
}
func (t MockTool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) {
return parameters.EmbedParams(ctx, t.Params, paramValues, embeddingModelsMap, nil)
}
func (t MockTool) Manifest() tools.Manifest {
pMs := make([]parameters.ParameterManifest, 0, len(t.Params))
for _, p := range t.Params {
pMs = append(pMs, p.Manifest())
}
return tools.Manifest{Description: t.Description, Parameters: pMs}
}
func (t MockTool) Authorized(verifiedAuthServices []string) bool {
// defaulted to true
return !t.unauthorized
}
func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) {
// defaulted to false
return t.requiresClientAuthrorization, nil
}
func (t MockTool) GetParameters() parameters.Parameters {
return t.Params
}
func (t MockTool) McpManifest() tools.McpManifest {
properties := make(map[string]parameters.ParameterMcpManifest)
required := make([]string, 0)
authParams := make(map[string][]string)
for _, p := range t.Params {
name := p.GetName()
paramManifest, authParamList := p.McpManifest()
properties[name] = paramManifest
required = append(required, name)
if len(authParamList) > 0 {
authParams[name] = authParamList
}
}
toolsSchema := parameters.McpToolsSchema{
Type: "object",
Properties: properties,
Required: required,
}
mcpManifest := tools.McpManifest{
Name: t.Name,
Description: t.Description,
InputSchema: toolsSchema,
}
if len(authParams) > 0 {
mcpManifest.Metadata = map[string]any{
"toolbox/authParams": authParams,
}
}
return mcpManifest
}
func (t MockTool) GetAuthTokenHeaderName(tools.SourceProvider) (string, error) {
return "Authorization", nil
}
// MockPrompt is used to mock prompts in tests
type MockPrompt struct {
Name string
Description string
Args prompts.Arguments
}
func (p MockPrompt) SubstituteParams(vals parameters.ParamValues) (any, error) {
return []prompts.Message{
{
Role: "user",
Content: fmt.Sprintf("substituted %s", p.Name),
},
}, nil
}
func (p MockPrompt) ParseArgs(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) {
var params parameters.Parameters
for _, arg := range p.Args {
params = append(params, arg.Parameter)
}
return parameters.ParseParams(params, data, claimsMap)
}
func (p MockPrompt) Manifest() prompts.Manifest {
var argManifests []parameters.ParameterManifest
for _, arg := range p.Args {
argManifests = append(argManifests, arg.Manifest())
}
return prompts.Manifest{
Description: p.Description,
Arguments: argManifests,
}
}
func (p MockPrompt) McpManifest() prompts.McpManifest {
return prompts.GetMcpManifest(p.Name, p.Description, p.Args)
}
func (p MockPrompt) ToConfig() prompts.PromptConfig {
return nil
}

View File

@@ -1,120 +0,0 @@
// Copyright 2026 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 spanneradmin
import (
"context"
"fmt"
instance "cloud.google.com/go/spanner/admin/instance/apiv1"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/util"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
"google.golang.org/api/option"
)
const SourceType string = "spanner-admin"
// validate interface
var _ sources.SourceConfig = Config{}
func init() {
if !sources.Register(SourceType, newConfig) {
panic(fmt.Sprintf("source type %q already registered", SourceType))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
DefaultProject string `yaml:"defaultProject"`
UseClientOAuth bool `yaml:"useClientOAuth"`
}
func (r Config) SourceConfigType() string {
return SourceType
}
// Initialize initializes a Spanner Admin Source instance.
func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
var client *instance.InstanceAdminClient
if !r.UseClientOAuth {
ua, err := util.UserAgentFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("error in User Agent retrieval: %s", err)
}
// Use Application Default Credentials
client, err = instance.NewInstanceAdminClient(ctx, option.WithUserAgent(ua))
if err != nil {
return nil, fmt.Errorf("error creating new spanner instance admin client: %w", err)
}
}
s := &Source{
Config: r,
Client: client,
}
return s, nil
}
var _ sources.Source = &Source{}
type Source struct {
Config
Client *instance.InstanceAdminClient
}
func (s *Source) SourceType() string {
return SourceType
}
func (s *Source) ToConfig() sources.SourceConfig {
return s.Config
}
func (s *Source) GetDefaultProject() string {
return s.DefaultProject
}
func (s *Source) GetClient(ctx context.Context, accessToken string) (*instance.InstanceAdminClient, error) {
if s.UseClientOAuth {
token := &oauth2.Token{AccessToken: accessToken}
ua, err := util.UserAgentFromContext(ctx)
if err != nil {
return nil, err
}
client, err := instance.NewInstanceAdminClient(ctx, option.WithTokenSource(oauth2.StaticTokenSource(token)), option.WithUserAgent(ua))
if err != nil {
return nil, fmt.Errorf("error creating new spanner instance admin client: %w", err)
}
return client, nil
}
return s.Client, nil
}
func (s *Source) UseClientAuthorization() bool {
return s.UseClientOAuth
}

View File

@@ -1,122 +0,0 @@
// Copyright 2026 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 spanneradmin_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/sources/spanneradmin"
"github.com/googleapis/genai-toolbox/internal/testutils"
)
func TestParseFromYamlSpannerAdmin(t *testing.T) {
tcs := []struct {
desc string
in string
want server.SourceConfigs
}{
{
desc: "basic example",
in: `
kind: sources
name: my-spanner-admin-instance
type: spanner-admin
`,
want: map[string]sources.SourceConfig{
"my-spanner-admin-instance": spanneradmin.Config{
Name: "my-spanner-admin-instance",
Type: spanneradmin.SourceType,
UseClientOAuth: false,
},
},
},
{
desc: "use client auth example",
in: `
kind: sources
name: my-spanner-admin-instance
type: spanner-admin
useClientOAuth: true
`,
want: map[string]sources.SourceConfig{
"my-spanner-admin-instance": spanneradmin.Config{
Name: "my-spanner-admin-instance",
Type: spanneradmin.SourceType,
UseClientOAuth: true,
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got, _, _, _, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in))
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if !cmp.Equal(tc.want, got) {
t.Fatalf("incorrect parse: want %v, got %v", tc.want, got)
}
})
}
}
func TestFailParseFromYaml(t *testing.T) {
t.Parallel()
tcs := []struct {
desc string
in string
err string
}{
{
desc: "extra field",
in: `
kind: sources
name: my-spanner-admin-instance
type: spanner-admin
project: test-project
`,
err: `error unmarshaling sources: unable to parse source "my-spanner-admin-instance" as "spanner-admin": [2:1] unknown field "project"
1 | name: my-spanner-admin-instance
> 2 | project: test-project
^
3 | type: spanner-admin`,
},
{
desc: "missing required field",
in: `
kind: sources
name: my-spanner-admin-instance
useClientOAuth: true
`,
err: "error unmarshaling sources: missing 'type' field or it is not a string",
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
_, _, _, _, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in))
if err == nil {
t.Fatalf("expect parsing to fail")
}
errStr := err.Error()
if errStr != tc.err {
t.Fatalf("unexpected error: got %q, want %q", errStr, tc.err)
}
})
}
}

View File

@@ -1,237 +0,0 @@
// Copyright 2026 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 spannercreateinstance
import (
"context"
"fmt"
instance "cloud.google.com/go/spanner/admin/instance/apiv1"
"cloud.google.com/go/spanner/admin/instance/apiv1/instancepb"
"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
)
const resourceType string = "spanner-create-instance"
func init() {
if !tools.Register(resourceType, newConfig) {
panic(fmt.Sprintf("tool type %q already registered", resourceType))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type compatibleSource interface {
GetDefaultProject() string
GetClient(context.Context, string) (*instance.InstanceAdminClient, error)
UseClientAuthorization() bool
}
// Config defines the configuration for the create-instance tool.
type Config struct {
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
Description string `yaml:"description"`
Source string `yaml:"source" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
// validate interface
var _ tools.ToolConfig = Config{}
// ToolConfigKind returns the kind of the tool.
func (cfg Config) ToolConfigType() string {
return resourceType
}
// Initialize initializes the tool from the configuration.
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
s, ok := rawS.(compatibleSource)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source %q not compatible", resourceType, cfg.Source)
}
project := s.GetDefaultProject()
var projectParam parameters.Parameter
if project != "" {
projectParam = parameters.NewStringParameterWithDefault("project", project, "The GCP project ID.")
} else {
projectParam = parameters.NewStringParameter("project", "The project ID")
}
allParameters := parameters.Parameters{
projectParam,
parameters.NewStringParameter("instanceId", "The ID of the instance"),
parameters.NewStringParameter("displayName", "The display name of the instance"),
parameters.NewStringParameter("config", "The instance configuration (e.g., regional-us-central1)"),
parameters.NewIntParameter("nodeCount", "The number of nodes, mutually exclusive with processingUnits (one must be 0)"),
parameters.NewIntParameter("processingUnits", "The number of processing units, mutually exclusive with nodeCount (one must be 0)"),
parameters.NewStringParameter("edition", "The edition of the instance (STANDARD, ENTERPRISE, ENTERPRISE_PLUS)"),
}
paramManifest := allParameters.Manifest()
description := cfg.Description
if description == "" {
description = "Creates a Spanner instance."
}
mcpManifest := tools.GetMcpManifest(cfg.Name, description, cfg.AuthRequired, allParameters, nil)
return Tool{
Config: cfg,
AllParams: allParameters,
manifest: tools.Manifest{Description: description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}, nil
}
// Tool represents the create-instance tool.
type Tool struct {
Config
AllParams parameters.Parameters
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) ToConfig() tools.ToolConfig {
return t.Config
}
// Invoke executes the tool's logic.
func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) {
paramsMap := params.AsMap()
project, _ := paramsMap["project"].(string)
instanceId, _ := paramsMap["instanceId"].(string)
displayName, _ := paramsMap["displayName"].(string)
config, _ := paramsMap["config"].(string)
nodeCount, _ := paramsMap["nodeCount"].(int)
processingUnits, _ := paramsMap["processingUnits"].(int)
editionStr, _ := paramsMap["edition"].(string)
if (nodeCount > 0 && processingUnits > 0) || (nodeCount == 0 && processingUnits == 0) {
return nil, fmt.Errorf("one of nodeCount or processingUnits must be positive, and the other must be 0")
}
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
if err != nil {
return nil, err
}
client, err := source.GetClient(ctx, string(accessToken))
if err != nil {
return nil, err
}
if source.UseClientAuthorization() {
defer client.Close()
}
parent := fmt.Sprintf("projects/%s", project)
instanceConfig := fmt.Sprintf("projects/%s/instanceConfigs/%s", project, config)
var edition instancepb.Instance_Edition
switch editionStr {
case "STANDARD":
edition = instancepb.Instance_STANDARD
case "ENTERPRISE":
edition = instancepb.Instance_ENTERPRISE
case "ENTERPRISE_PLUS":
edition = instancepb.Instance_ENTERPRISE_PLUS
default:
edition = instancepb.Instance_EDITION_UNSPECIFIED
}
// Construct the instance object
instance := &instancepb.Instance{
Config: instanceConfig,
DisplayName: displayName,
Edition: edition,
NodeCount: int32(nodeCount),
ProcessingUnits: int32(processingUnits),
}
req := &instancepb.CreateInstanceRequest{
Parent: parent,
InstanceId: instanceId,
Instance: instance,
}
op, err := client.CreateInstance(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create instance: %w", err)
}
// Wait for the operation to complete
resp, err := op.Wait(ctx)
if err != nil {
return nil, fmt.Errorf("failed to wait for create instance operation: %w", err)
}
return resp, nil
}
// ParseParams parses the parameters for the tool.
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (parameters.ParamValues, error) {
return parameters.ParseParams(t.AllParams, data, claims)
}
func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) {
return parameters.EmbedParams(ctx, t.AllParams, paramValues, embeddingModelsMap, nil)
}
// Manifest returns the tool's manifest.
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
// McpManifest returns the tool's MCP manifest.
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
// Authorized checks if the tool is authorized.
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return true
}
func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) {
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
if err != nil {
return false, err
}
return source.UseClientAuthorization(), nil
}
func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) {
return "Authorization", nil
}
func (t Tool) GetParameters() parameters.Parameters {
return t.AllParams
}

View File

@@ -1,106 +0,0 @@
// Copyright 2026 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 spannercreateinstance_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/internal/tools/spanneradmin/spannercreateinstance"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
)
func TestParseFromYaml(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
kind: tools
name: create-instance-tool
type: spanner-create-instance
description: a test description
source: a-source
`,
want: server.ToolConfigs{
"create-instance-tool": spannercreateinstance.Config{
Name: "create-instance-tool",
Type: "spanner-create-instance",
Description: "a test description",
Source: "a-source",
AuthRequired: []string{},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
// Parse contents
_, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in))
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}
func TestInvokeNodeCountAndProcessingUnitsValidation(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
params parameters.ParamValues
wantErr string
}{
{
name: "Both positive",
params: parameters.ParamValues{
{Name: "nodeCount", Value: 1},
{Name: "processingUnits", Value: 1000},
},
wantErr: "one of nodeCount or processingUnits must be positive, and the other must be 0",
},
{
name: "Both zero",
params: parameters.ParamValues{
{Name: "nodeCount", Value: 0},
{Name: "processingUnits", Value: 0},
},
wantErr: "one of nodeCount or processingUnits must be positive, and the other must be 0",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tool := spannercreateinstance.Tool{}
_, err := tool.Invoke(context.Background(), nil, tc.params, "")
if err == nil || err.Error() != tc.wantErr {
t.Errorf("Invoke() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}

View File

@@ -1,178 +0,0 @@
// Copyright 2026 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 spanneradmin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"testing"
"time"
instance "cloud.google.com/go/spanner/admin/instance/apiv1"
"cloud.google.com/go/spanner/admin/instance/apiv1/instancepb"
"github.com/google/uuid"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
)
var (
SpannerProject = os.Getenv("SPANNER_PROJECT")
)
func getSpannerAdminVars(t *testing.T) map[string]any {
if SpannerProject == "" {
t.Fatal("'SPANNER_PROJECT' not set")
}
return map[string]any{
"type": "spanner-admin",
"defaultProject": SpannerProject,
}
}
func TestSpannerAdminCreateInstance(t *testing.T) {
sourceConfig := getSpannerAdminVars(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
shortUuid := strings.ReplaceAll(uuid.New().String(), "-", "")[:10]
instanceId := "test-inst-" + shortUuid
displayName := "Test Instance " + shortUuid
instanceConfig := "regional-us-central1"
nodeCount := 1
edition := "ENTERPRISE"
// Setup Admin Client for verification and cleanup
adminClient, err := instance.NewInstanceAdminClient(ctx)
if err != nil {
t.Fatalf("unable to create Spanner instance admin client: %s", err)
}
defer adminClient.Close()
// Teardown function
defer func() {
err := adminClient.DeleteInstance(ctx, &instancepb.DeleteInstanceRequest{
Name: fmt.Sprintf("projects/%s/instances/%s", SpannerProject, instanceId),
})
if err != nil {
// If it fails, it might not have been created, log it but don't fail if it's "not found"
t.Logf("cleanup: failed to delete instance %s: %s", instanceId, err)
} else {
t.Logf("cleanup: deleted instance %s", instanceId)
}
}()
// Construct Tools Config
toolsConfig := map[string]any{
"sources": map[string]any{
"my-spanner-admin": sourceConfig,
},
"tools": map[string]any{
"create-instance-tool": map[string]any{
"type": "spanner-create-instance",
"source": "my-spanner-admin",
"description": "Creates a Spanner instance.",
},
},
}
// Start Toolbox Server
cmd, cleanup, err := tests.StartCmd(ctx, toolsConfig)
if err != nil {
t.Fatalf("command initialization returned an error: %s", err)
}
defer cleanup()
waitCtx, cancelWait := context.WithTimeout(ctx, 10*time.Second)
defer cancelWait()
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)
}
// Prepare Invocation Payload
payload := map[string]any{
"project": SpannerProject,
"instanceId": instanceId,
"displayName": displayName,
"config": instanceConfig,
"nodeCount": nodeCount,
"edition": edition,
"processingUnits": 0,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal payload: %s", err)
}
// Invoke Tool
invokeUrl := "http://127.0.0.1:5000/api/tool/create-instance-tool/invoke"
req, err := http.NewRequest(http.MethodPost, invokeUrl, bytes.NewBuffer(payloadBytes))
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
t.Logf("Invoking create-instance-tool for instance: %s", instanceId)
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 {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Check Response
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body")
}
// Verify Instance Exists via Admin Client
t.Logf("Verifying instance %s exists...", instanceId)
instanceName := fmt.Sprintf("projects/%s/instances/%s", SpannerProject, instanceId)
gotInstance, err := adminClient.GetInstance(ctx, &instancepb.GetInstanceRequest{
Name: instanceName,
})
if err != nil {
t.Fatalf("failed to get instance from admin client: %s", err)
}
if gotInstance.Name != instanceName {
t.Errorf("expected instance name %s, got %s", instanceName, gotInstance.Name)
}
if gotInstance.DisplayName != displayName {
t.Errorf("expected display name %s, got %s", displayName, gotInstance.DisplayName)
}
if gotInstance.NodeCount != int32(nodeCount) {
t.Errorf("expected node count %d, got %d", nodeCount, gotInstance.NodeCount)
}
}