From 2e90e65c2d68f3d9e88e751cea7bca98f184884e Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:12:25 +0800 Subject: [PATCH] fix(prompt/custom): parse typed MCP argument strings --- .../configuration/prompts/_index.md | 4 + internal/prompts/arguments.go | 20 +++- internal/prompts/arguments_test.go | 98 +++++++++++++++++++ .../custom/prompts_integration_test.go | 88 ++++++++++++++++- 4 files changed, 207 insertions(+), 3 deletions(-) diff --git a/docs/en/documentation/configuration/prompts/_index.md b/docs/en/documentation/configuration/prompts/_index.md index 7a7ee616a5e1..830e9abf74ae 100644 --- a/docs/en/documentation/configuration/prompts/_index.md +++ b/docs/en/documentation/configuration/prompts/_index.md @@ -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 diff --git a/internal/prompts/arguments.go b/internal/prompts/arguments.go index 1e83e712d67c..1366f1e8ba78 100644 --- a/internal/prompts/arguments.go +++ b/internal/prompts/arguments.go @@ -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" @@ -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 @@ -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) } diff --git a/internal/prompts/arguments_test.go b/internal/prompts/arguments_test.go index ace3dfba5dfc..65c630491077 100644 --- a/internal/prompts/arguments_test.go +++ b/internal/prompts/arguments_test.go @@ -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) + } + }) + } +} diff --git a/tests/prompts/custom/prompts_integration_test.go b/tests/prompts/custom/prompts_integration_test.go index 83256581cbfb..caed7727f69c 100644 --- a/tests/prompts/custom/prompts_integration_test.go +++ b/tests/prompts/custom/prompts_integration_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "regexp" @@ -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}}`, + }, + }, + }, }, } } @@ -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() @@ -96,6 +124,7 @@ func TestMCPPromptsIntegration(t *testing.T) { runMCPListPromptsTest(t) runMCPGetPromptTest(t) + runMCPGetTypedPromptTest(t) } func runMCPListPromptsTest(t *testing.T) { @@ -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)) } }) } @@ -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) + } + }) +}