diff --git a/pkg/mcp/instructions.md b/pkg/mcp/instructions.md index 45af704c81..9cb9a3e3f2 100644 --- a/pkg/mcp/instructions.md +++ b/pkg/mcp/instructions.md @@ -33,6 +33,7 @@ This is essential because: - `create` tool: path = absolute path to directory where Function will be created - `deploy` tool: path = absolute path to Function directory (where func.yaml exists) - `build` tool: path = absolute path to Function directory (where func.yaml exists) +- `invoke` tool: path = absolute path to Function directory (where func.yaml exists) - `config_*_list`, `config_*_add`, `config_*_remove` tools: path = absolute path to Function directory (where func.yaml exists) **IMPORTANT:** You must use absolute paths (e.g., `/Users/name/myproject/myfunc`), NOT relative paths (e.g., `.` or `myfunc`). The MCP server process runs in a different directory than your current working directory, so relative paths will not resolve correctly. @@ -56,6 +57,7 @@ This is essential because: - Before 'create' → Read `func://help/create` - Before 'deploy' → Read `func://help/deploy` - Before 'build' → Read `func://help/build` +- Before 'invoke' → Read `func://help/invoke` - Before 'list' → Read `func://help/list` - Before 'delete' → Read `func://help/delete` @@ -124,6 +126,16 @@ A first-time deploy can be detected by checking the func.yaml for a value in the - Uses same builder settings as deploy would use - The user should be notified this is an unnecessary step if they intend to deploy, as building is handled as part of deployment +### invoke + +- **FIRST:** Read `func://help/invoke` for authoritative usage information +- Sends a test request to a running Function instance, either local or remote +- **REQUIRED parameters:** + - `path` (directory containing the Function to invoke) +- **Optional** `target` parameter: "local", "remote", or a URL. Defaults to preferring a locally running instance over remote +- **CAUTION:** Invoking a Function executes its handler and may trigger arbitrary, real side effects (e.g. sending an email, charging a payment, writing to a database) — this is especially true with `target: "remote"`, which hits the live deployed instance. Do not invoke automatically without considering whether the Function's handler is safe to run; when in doubt, confirm with the user first, particularly before invoking a remote/production instance +- An error is returned if the invocation fails (e.g. non-2xx HTTP response), so a successful call without an error confirms the Function responded correctly + ### list - **FIRST:** Read `func://help/list` for authoritative usage information diff --git a/pkg/mcp/instructions_warning.md b/pkg/mcp/instructions_warning.md index 98697ddeec..fefca96b0f 100644 --- a/pkg/mcp/instructions_warning.md +++ b/pkg/mcp/instructions_warning.md @@ -15,12 +15,13 @@ The Functions MCP server is currently running in **read-only mode**. **Disabled operations:** - Deploy to cluster - Delete from cluster +- Invoke a Function -These write operations are disabled to prevent unintended cluster modifications. +These operations are disabled to prevent unintended cluster modifications and side effects from invoking a Function's handler. ## Enabling Write Mode -If the user needs to deploy or delete Functions, you MUST inform them to enable write mode: +If the user needs to deploy, delete, or invoke Functions, you MUST inform them to enable write mode: 1. Close/exit this application completely 2. Set the environment variable: `FUNC_ENABLE_MCP_WRITE=true` diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 02ea0f5d44..be377e46e6 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -24,7 +24,7 @@ const ( type Server struct { OnInit func(context.Context) // Invoked when the server is initialized prefix string // Command prefix ("func" or "kn func") - readonly atomic.Bool // disables deploy and delete when true + readonly atomic.Bool // disables deploy, delete, build, and invoke when true executor executor transport mcp.Transport // Transport to use (defaults to StdioTransport) impl *mcp.Server // implements the protocol @@ -107,6 +107,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) @@ -137,6 +138,7 @@ func New(options ...Option) *Server { i.AddResource(newHelpResource(s, "Create Help", "help for 'create'", "create")) i.AddResource(newHelpResource(s, "Build Help", "help for 'build'", "build")) i.AddResource(newHelpResource(s, "Deploy Help", "help for 'deploy'", "deploy")) + i.AddResource(newHelpResource(s, "Invoke Help", "help for 'invoke'", "invoke")) i.AddResource(newHelpResource(s, "List Help", "help for 'list'", "list")) i.AddResource(newHelpResource(s, "Delete Help", "help for delete", "delete")) diff --git a/pkg/mcp/tools_invoke.go b/pkg/mcp/tools_invoke.go new file mode 100644 index 0000000000..b39cb7cf6e --- /dev/null +++ b/pkg/mcp/tools_invoke.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "context" + "fmt" + "sort" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var invokeTool = &mcp.Tool{ + Name: "invoke", + Title: "Invoke Function", + Description: "Invoke a local or remote Function with a test request.", + Annotations: &mcp.ToolAnnotations{ + Title: "Invoke Function", + ReadOnlyHint: false, + DestructiveHint: ptr(true), // Invoking a Function may trigger arbitrary, unrepeatable side effects in the Function's handler (e.g. sending an email, charging a payment). + IdempotentHint: false, // Invoking a Function may trigger arbitrary side effects in the Function's handler. + }, +} + +func (s *Server) invokeHandler(ctx context.Context, r *mcp.CallToolRequest, input InvokeInput) (result *mcp.CallToolResult, output InvokeOutput, err error) { + if s.readonly.Load() { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + + 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. +type InvokeInput struct { + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Target *string `json:"target,omitempty" jsonschema:"Function instance to invoke: local, remote, or a URL (default: auto-discovery; prefers local when both local and remote are running)"` + Format *string `json:"format,omitempty" jsonschema:"Format of message to send: http or cloudevent (default: auto-detected)"` + ID *string `json:"id,omitempty" jsonschema:"CloudEvent id for the request data"` + Source *string `json:"source,omitempty" jsonschema:"CloudEvent source for the request data"` + Type *string `json:"type,omitempty" jsonschema:"CloudEvent type for the request data"` + Data *string `json:"data,omitempty" jsonschema:"Data (content) to send in the request"` + ContentType *string `json:"contentType,omitempty" jsonschema:"MIME type of the data"` + RequestType *string `json:"requestType,omitempty" jsonschema:"HTTP method override (e.g., GET, POST)"` + File *string `json:"file,omitempty" jsonschema:"Path to a file whose content is used as the request data (overrides data)"` + Extensions map[string]string `json:"extensions,omitempty" jsonschema:"CloudEvent extension attributes as key-value pairs (cloudevent format only)"` + Insecure *bool `json:"insecure,omitempty" jsonschema:"Skip TLS verification when invoking over SSL"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i InvokeInput) Args() []string { + args := []string{"--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) + + if len(i.Extensions) > 0 { + keys := make([]string, 0, len(i.Extensions)) + for k := range i.Extensions { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, "--extension", fmt.Sprintf("%s=%s", k, i.Extensions[k])) + } + } + + 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:"Output message, including the response body from the invoked Function"` +} diff --git a/pkg/mcp/tools_invoke_test.go b/pkg/mcp/tools_invoke_test.go new file mode 100644 index 0000000000..b3c4129130 --- /dev/null +++ b/pkg/mcp/tools_invoke_test.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "context" + "errors" + "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) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "target": {"target", "--target", "remote"}, + "format": {"format", "--format", "http"}, + "id": {"id", "--id", "test-id"}, + "source": {"source", "--source", "test-source"}, + "type": {"type", "--type", "test-type"}, + "data": {"data", "--data", "hello world"}, + "contentType": {"contentType", "--content-type", "text/plain"}, + "requestType": {"requestType", "--request-type", "GET"}, + "file": {"file", "--file", "example.jpeg"}, + } + + 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("Received: 200 OK\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + + // Invoke tool with all optional arguments + 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_Extensions ensures CloudEvent extension attributes are +// passed as repeated, deterministically-ordered '--extension key=value' flags. +func TestTool_Invoke_Extensions(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) + } + want := []string{ + "--path", ".", + "--extension", "priority=high", + "--extension", "region=us-east", + } + if len(args) != len(want) { + t.Fatalf("expected args %v, got %v", want, args) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("expected args %v, got %v", want, args) + } + } + return []byte("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{ + "path": ".", + "extensions": map[string]any{ + "region": "us-east", + "priority": "high", + }, + }, + }) + 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_MinimalArgs ensures the invoke tool can be called with only +// the required 'path' argument, relying on defaults for everything else +// (target auto-discovers between local and remote). +func TestTool_Invoke_MinimalArgs(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) + } + want := []string{"--path", "."} + if len(args) != len(want) || args[0] != want[0] || args[1] != want[1] { + t.Fatalf("expected args %v, got %v", want, args) + } + return []byte("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{"path": "."}, + }) + 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_MissingPath ensures the invoke tool rejects a call that +// omits the required 'path' argument, since the MCP server's own working +// directory is unrelated to the Function being tested. +func TestTool_Invoke_MissingPath(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + t.Fatal("executor should not be invoked when 'path' is missing") + return nil, nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + _, err = client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when 'path' argument is missing") + } +} + +// TestTool_Invoke_Readonly ensures the invoke tool rejects requests in +// readonly mode, since invoking a Function may trigger arbitrary side +// effects in its handler. +func TestTool_Invoke_Readonly(t *testing.T) { + client, _, err := newTestPairWithReadonly(t, true) // readonly = true + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "invoke", + Arguments: map[string]any{"path": "."}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected invoke to be rejected in readonly mode") + } +} + +// TestTool_Invoke_Error ensures a failing invocation (e.g. non-2xx response) +// is surfaced as a tool error, allowing agents to detect invocation failures. +func TestTool_Invoke_Error(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte(""), errors.New("failure invoking function (HTTP 500)") + } + + 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{"path": "."}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected invoke failure to be reported as a tool error") + } +}