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
12 changes: 12 additions & 0 deletions pkg/mcp/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`

Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions pkg/mcp/instructions_warning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"))

Expand Down
90 changes: 90 additions & 0 deletions pkg/mcp/tools_invoke.go
Original file line number Diff line number Diff line change
@@ -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"`
}
228 changes: 228 additions & 0 deletions pkg/mcp/tools_invoke_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading