From cd1e2db7956ca8c5a6eaf47c1c54b6b08bd82afe Mon Sep 17 00:00:00 2001 From: Thonmay Date: Fri, 15 May 2026 00:09:30 +0200 Subject: [PATCH] mcp: add invoke tool to test deployed Functions from agent context Adds a new MCP 'invoke' tool that wraps 'func invoke', completing the end-to-end Function lifecycle exposed to agents: create -> build -> deploy -> invoke The tool supports all non-interactive flags: path, target (local/ remote/URL), format (http/cloudevent), id, source, type, data, content-type, request-type, file, insecure, and verbose. All fields are optional; with no arguments the function in the current working directory is invoked with auto-discovered target. ReadOnlyHint is false because invoking a function can have side effects within the function itself. IdempotentHint is false for the same reason. Fixes #3727 Signed-off-by: Thonmay --- pkg/mcp/mcp.go | 1 + pkg/mcp/tools_invoke.go | 74 +++++++++++++++ pkg/mcp/tools_invoke_test.go | 175 +++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 pkg/mcp/tools_invoke.go create mode 100644 pkg/mcp/tools_invoke_test.go diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index b43715ee34..324105007e 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -94,6 +94,7 @@ func New(options ...Option) *Server { mcp.AddTool(i, createTool, s.createHandler) mcp.AddTool(i, buildTool, s.buildHandler) mcp.AddTool(i, deployTool, s.deployHandler) + mcp.AddTool(i, invokeTool, s.invokeHandler) mcp.AddTool(i, listTool, s.listHandler) mcp.AddTool(i, deleteTool, s.deleteHandler) mcp.AddTool(i, configVolumesListTool, s.configVolumesListHandler) diff --git a/pkg/mcp/tools_invoke.go b/pkg/mcp/tools_invoke.go new file mode 100644 index 0000000000..e23bc3a9f4 --- /dev/null +++ b/pkg/mcp/tools_invoke.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var invokeTool = &mcp.Tool{ + Name: "invoke", + Title: "Invoke Function", + Description: "Invoke a deployed Function to test and validate it is working correctly. Sends an HTTP request or CloudEvent to the Function and returns the response. Use this after deploying to verify the Function handles requests as expected.", + Annotations: &mcp.ToolAnnotations{ + Title: "Invoke Function", + ReadOnlyHint: false, // Invoking a function can have side effects within the function itself. + IdempotentHint: false, + }, +} + +func (s *Server) invokeHandler(ctx context.Context, r *mcp.CallToolRequest, input InvokeInput) (result *mcp.CallToolResult, output InvokeOutput, err error) { + out, err := s.executor.Execute(ctx, "invoke", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = InvokeOutput{ + Message: string(out), + } + return +} + +// InvokeInput defines the input parameters for the invoke tool. +// All fields are optional since invoke can work with no arguments, +// using the current working directory and auto-discovering the target. +type InvokeInput struct { + Path *string `json:"path,omitempty" jsonschema:"Path to the function project directory (defaults to current directory)"` + Target *string `json:"target,omitempty" jsonschema:"Target to invoke: 'local' for a locally-running instance, 'remote' for the cluster deployment, or a direct URL"` + Format *string `json:"format,omitempty" jsonschema:"Request format: 'http' for plain HTTP request or 'cloudevent' for CloudEvents format"` + ID *string `json:"id,omitempty" jsonschema:"Request ID for correlation (used in CloudEvents as the event ID)"` + Source *string `json:"source,omitempty" jsonschema:"Request source identifier (used in CloudEvents as the event source)"` + Type *string `json:"type,omitempty" jsonschema:"Request type (used in CloudEvents as the event type)"` + Data *string `json:"data,omitempty" jsonschema:"Request data/body to send to the Function"` + ContentType *string `json:"contentType,omitempty" jsonschema:"MIME type of the request data (e.g., application/json, text/plain)"` + RequestType *string `json:"requestType,omitempty" jsonschema:"HTTP method to use: 'GET' or 'POST'"` + File *string `json:"file,omitempty" jsonschema:"Path to a file whose contents will be used as request data"` + Insecure *bool `json:"insecure,omitempty" jsonschema:"Allow insecure connections (skip TLS certificate verification)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i InvokeInput) Args() []string { + var args []string + + args = appendStringFlag(args, "--path", i.Path) + args = appendStringFlag(args, "--target", i.Target) + args = appendStringFlag(args, "--format", i.Format) + args = appendStringFlag(args, "--id", i.ID) + args = appendStringFlag(args, "--source", i.Source) + args = appendStringFlag(args, "--type", i.Type) + args = appendStringFlag(args, "--data", i.Data) + args = appendStringFlag(args, "--content-type", i.ContentType) + args = appendStringFlag(args, "--request-type", i.RequestType) + args = appendStringFlag(args, "--file", i.File) + + args = appendBoolFlag(args, "--insecure", i.Insecure) + args = appendBoolFlag(args, "--verbose", i.Verbose) + + return args +} + +// InvokeOutput defines the structured output returned by the invoke tool. +type InvokeOutput struct { + Message string `json:"message" jsonschema:"Function response output"` +} diff --git a/pkg/mcp/tools_invoke_test.go b/pkg/mcp/tools_invoke_test.go new file mode 100644 index 0000000000..96c573015b --- /dev/null +++ b/pkg/mcp/tools_invoke_test.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Invoke_Args ensures the invoke tool executes with all arguments passed correctly. +func TestTool_Invoke_Args(t *testing.T) { + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "/tmp/my-func"}, + "target": {"target", "--target", "remote"}, + "format": {"format", "--format", "cloudevent"}, + "id": {"id", "--id", "test-id-123"}, + "source": {"source", "--source", "/my/source"}, + "type": {"type", "--type", "my.event.type"}, + "data": {"data", "--data", `{"key":"value"}`}, + "contentType": {"contentType", "--content-type", "application/json"}, + "requestType": {"requestType", "--request-type", "POST"}, + "file": {"file", "--file", "/tmp/payload.json"}, + } + + boolFlags := map[string]string{ + "insecure": "--insecure", + "verbose": "--verbose", + } + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "invoke" { + t.Fatalf("expected subcommand 'invoke', got %q", subcommand) + } + + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + + return []byte("HTTP/1.1 200 OK\nHello World\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + inputArgs := buildInputArgs(stringFlags, boolFlags) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: inputArgs, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestTool_Invoke_NoArgs ensures invoke works with no arguments (cwd-based invocation). +func TestTool_Invoke_NoArgs(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "invoke" { + t.Fatalf("expected subcommand 'invoke', got %q", subcommand) + } + if len(args) != 0 { + t.Fatalf("expected 0 args for no-args invoke, got %d: %v", len(args), args) + } + return []byte("HTTP/1.1 200 OK\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestTool_Invoke_RemoteTarget ensures the target flag is passed correctly for remote invocation. +func TestTool_Invoke_RemoteTarget(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "invoke" { + t.Fatalf("expected subcommand 'invoke', got %q", subcommand) + } + + argsMap := argsToMap(args) + if val, ok := argsMap["--target"]; !ok { + t.Fatal("missing --target flag") + } else if val != "remote" { + t.Fatalf("expected --target 'remote', got %q", val) + } + + return []byte("HTTP/1.1 200 OK\nResponse from remote\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: map[string]any{ + "target": "remote", + }, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestTool_Invoke_BinaryFailure ensures errors from the func binary are returned as MCP errors. +func TestTool_Invoke_BinaryFailure(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte("Error: function not found\n"), fmt.Errorf("exit status 1") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: map[string]any{ + "target": "remote", + }, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result when binary fails") + } + if !executor.ExecuteInvoked { + t.Fatal("executor should have been invoked") + } + if !strings.Contains(resultToString(result), "function not found") { + t.Errorf("expected error to include binary output, got: %s", resultToString(result)) + } +}