Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/en/documentation/configuration/prompts/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ arguments:
An argument can be any [Parameter](../tools/_index.md#specifying-parameters)
type. If the `type` field is not specified, it will default to `string`.

MCP clients pass argument values as strings. For a non-string parameter, use its
JSON representation, such as `3`, `false`, `[1, 2]`, or `{"count": 3}`. String
arguments are used as written.

## Usage with Gemini CLI

Prompts defined in your `tools.yaml` can be seamlessly integrated with the
Expand Down
20 changes: 19 additions & 1 deletion internal/prompts/arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ package prompts

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"

"github.com/googleapis/mcp-toolbox/internal/util"
"github.com/googleapis/mcp-toolbox/internal/util/parameters"
Expand All @@ -28,6 +31,21 @@ type Argument struct {
parameters.Parameter
}

// Parse converts MCP string arguments before validating their declared parameter type.
func (arg Argument) Parse(value any) (any, error) {
if text, ok := value.(string); ok && arg.GetType() != parameters.TypeString {
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("unable to decode argument %q: %w", arg.GetName(), err)
}
if err := decoder.Decode(new(any)); err != io.EOF {
return nil, fmt.Errorf("argument %q must contain a single JSON value", arg.GetName())
}
}
return arg.Parameter.Parse(value)
}

// Arguments is a slice of Argument.
type Arguments []Argument

Expand Down Expand Up @@ -67,7 +85,7 @@ func (args *Arguments) UnmarshalYAML(ctx context.Context, unmarshal func(interfa
func ParseArguments(arguments Arguments, args map[string]any, data map[string]map[string]any) (parameters.ParamValues, error) {
var params parameters.Parameters
for _, arg := range arguments {
params = append(params, arg.Parameter)
params = append(params, arg)
}
return parameters.ParseParams(params, args, data)
}
98 changes: 98 additions & 0 deletions internal/prompts/arguments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,101 @@ func TestParseArguments(t *testing.T) {
})
}
}

func TestParseArgumentsStringValues(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
parameter parameters.Parameter
value any
want any
}{
{"integer", parameters.NewIntParameter("value", ""), "3", 3},
{"integer zero overrides default", parameters.NewIntParameter("value", "", parameters.WithIntDefault(5)), "0", 0},
{"float", parameters.NewFloatParameter("value", ""), "1.25", 1.25},
{"float zero overrides default", parameters.NewFloatParameter("value", "", parameters.WithFloatDefault(5)), "0", float64(0)},
{"boolean true", parameters.NewBooleanParameter("value", ""), "true", true},
{"boolean false overrides default", parameters.NewBooleanParameter("value", "", parameters.WithBooleanDefault(true)), "false", false},
{"integer array", parameters.NewArrayParameter("value", "", parameters.NewIntParameter("item", "")), "[0, 3]", []any{0, 3}},
{"integer map", parameters.NewMapParameter("value", "", "integer"), `{"count":0}`, map[string]any{"count": 0}},
{"generic map", parameters.NewMapParameter("value", "", ""), `{"count":3,"ratio":1.25}`, map[string]any{"count": int64(3), "ratio": 1.25}},
{"numeric string remains text", parameters.NewStringParameter("value", ""), "3", "3"},
{"JSON string remains text", parameters.NewStringParameter("value", ""), `{"enabled":false}`, `{"enabled":false}`},
{"typed integer", parameters.NewIntParameter("value", ""), 3, 3},
{"typed float", parameters.NewFloatParameter("value", ""), 1.25, 1.25},
{"typed boolean", parameters.NewBooleanParameter("value", ""), false, false},
{"typed array", parameters.NewArrayParameter("value", "", parameters.NewIntParameter("item", "")), []any{0, 3}, []any{0, 3}},
{"typed map", parameters.NewMapParameter("value", "", "integer"), map[string]any{"count": 3}, map[string]any{"count": 3}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
argsIn := map[string]any{"value": tc.value}
got, err := prompts.ParseArguments(prompts.Arguments{{Parameter: tc.parameter}}, argsIn, nil)
if err != nil {
t.Fatalf("ParseArguments() returned unexpected error: %v", err)
}
want := parameters.ParamValues{{Name: "value", Value: tc.want}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("ParseArguments() mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(map[string]any{"value": tc.value}, argsIn); diff != "" {
t.Errorf("ParseArguments() changed its input (-want +got):\n%s", diff)
}
})
}
}

func TestParseArgumentsDefaultsAndOptionalValues(t *testing.T) {
t.Parallel()
arguments := prompts.Arguments{
{Parameter: parameters.NewIntParameter("count", "", parameters.WithIntDefault(4))},
{Parameter: parameters.NewBooleanParameter("enabled", "", parameters.WithBooleanDefault(false))},
{Parameter: parameters.NewStringParameter("optional", "", parameters.WithStringRequired(false))},
}
got, err := prompts.ParseArguments(arguments, nil, nil)
if err != nil {
t.Fatalf("ParseArguments() returned unexpected error: %v", err)
}
want := parameters.ParamValues{
{Name: "count", Value: 4},
{Name: "enabled", Value: false},
{Name: "optional", Value: nil},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("ParseArguments() mismatch (-want +got):\n%s", diff)
}
}

func TestParseArgumentsValueFromParam(t *testing.T) {
t.Parallel()
count := parameters.NewIntParameter("count", "")
count.ValueFromParam = "source"
arguments := prompts.Arguments{
{Parameter: parameters.NewStringParameter("source", "")},
{Parameter: count},
}
argsIn := map[string]any{"source": "3"}
got, err := prompts.ParseArguments(arguments, argsIn, nil)
if err != nil {
t.Fatalf("ParseArguments() returned unexpected error: %v", err)
}
want := parameters.ParamValues{{Name: "source", Value: "3"}, {Name: "count", Value: 3}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("ParseArguments() mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(map[string]any{"source": "3"}, argsIn); diff != "" {
t.Errorf("ParseArguments() changed its input (-want +got):\n%s", diff)
}
}

func TestParseArgumentsInvalidStringValues(t *testing.T) {
t.Parallel()
for _, value := range []string{"unknown", "[3", "3 true", "true"} {
t.Run(value, func(t *testing.T) {
arguments := prompts.Arguments{{Parameter: parameters.NewIntParameter("value", "")}}
if _, err := prompts.ParseArguments(arguments, map[string]any{"value": value}, nil); err == nil {
t.Errorf("ParseArguments() accepted invalid integer argument %q", value)
}
})
}
}
88 changes: 86 additions & 2 deletions tests/prompts/custom/prompts_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
Expand Down Expand Up @@ -70,6 +71,24 @@ func getPromptsConfig() map[string]any {
},
},
},
"typed_prompt": map[string]any{
"arguments": []any{
map[string]any{"name": "count", "type": "integer", "description": "A count"},
map[string]any{"name": "ratio", "type": "float", "description": "A ratio"},
map[string]any{"name": "enabled", "type": "boolean", "description": "Whether enabled"},
map[string]any{
"name": "tags", "type": "array", "description": "Numeric tags",
"items": map[string]any{"name": "item", "type": "integer", "description": "A tag"},
},
map[string]any{"name": "limits", "type": "map", "valueType": "integer", "description": "Numeric limits"},
map[string]any{"name": "literal", "description": "Literal text"},
},
"messages": []any{
map[string]any{
"content": `count={{.count}} ratio={{.ratio}} enabled={{.enabled}} tags={{range .tags}}[{{.}}]{{end}} limit={{index .limits "max"}} literal={{.literal}}`,
},
},
},
},
}
}
Expand All @@ -85,6 +104,15 @@ func TestMCPPromptsIntegration(t *testing.T) {
t.Fatalf("command initialization returned an error: %v", err)
}
defer cleanup()
defer func() {
cmd.Stop()
cmd.Close()
stopCtx, cancelStop := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelStop()
if err := cmd.Wait(stopCtx); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("toolbox shutdown failed: %v", err)
}
}()

waitCtx, cancelWait := context.WithTimeout(ctx, 20*time.Second)
defer cancelWait()
Expand All @@ -96,6 +124,7 @@ func TestMCPPromptsIntegration(t *testing.T) {

runMCPListPromptsTest(t)
runMCPGetPromptTest(t)
runMCPGetTypedPromptTest(t)
}

func runMCPListPromptsTest(t *testing.T) {
Expand Down Expand Up @@ -127,8 +156,8 @@ func runMCPListPromptsTest(t *testing.T) {
t.Fatalf("failed to decode response: %v", err)
}

if len(result.Result.Prompts) != 2 {
t.Errorf("expected 2 prompts, got %d", len(result.Result.Prompts))
if len(result.Result.Prompts) != 3 {
t.Errorf("expected 3 prompts, got %d", len(result.Result.Prompts))
}
})
}
Expand Down Expand Up @@ -188,3 +217,58 @@ func runMCPGetPromptTest(t *testing.T) {
}
})
}

func runMCPGetTypedPromptTest(t *testing.T) {
t.Run("MCP_prompts/get_typed_arguments", func(t *testing.T) {
reqBody := jsonrpc.JSONRPCRequest{
Jsonrpc: "2.0",
Id: "get-typed-prompt",
Request: jsonrpc.Request{Method: "prompts/get"},
Params: map[string]any{
"name": "typed_prompt",
"arguments": map[string]string{
"count": "0", "ratio": "1.5", "enabled": "false",
"tags": "[0,3]", "limits": `{"max":5}`, "literal": `{"x":true}`,
},
},
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post("http://127.0.0.1:5000/mcp", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("failed to send request: %v", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status OK; got %s, body: %s", resp.Status, body)
}
var result struct {
Result struct {
Messages []struct {
Content struct{ Text string }
}
}
Error *struct {
Code int
Message string
}
}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result.Error != nil {
t.Fatalf("prompts/get returned an error: %+v", result.Error)
}
if len(result.Result.Messages) != 1 {
t.Fatalf("expected one prompt message, got %s", body)
}
want := `count=0 ratio=1.5 enabled=false tags=[0][3] limit=5 literal={"x":true}`
if got := result.Result.Messages[0].Content.Text; got != want {
t.Errorf("unexpected typed prompt content:\nGot: %q\nWant: %q", got, want)
}
})
}
Loading