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
14 changes: 14 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,18 @@ 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
- If `data` and `file` are both omitted, a real default payload is still sent: `{"message":"Hello World"}` with content-type `application/json`, source `/boson/fn`, type `boson.fn`, and method `POST`. This is NOT an empty/no-op probe — the Function's handler actually runs against this payload
- **If using `file`:** you MUST pass an ABSOLUTE path, just like `path`. `file` is resolved relative to the MCP server process's working directory (NOT your current working directory and NOT relative to the `path` parameter), so a relative value will often fail or read the wrong file
- **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
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, and build 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
88 changes: 88 additions & 0 deletions pkg/mcp/tools_invoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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) {
// Note: invoke is intentionally allowed in readonly mode. Readonly mode
// only disables operations that mutate cluster state (deploy, delete,
// build); invoking a Function does not itself modify the cluster.
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 (default: a randomly generated UUID)"`
Source *string `json:"source,omitempty" jsonschema:"CloudEvent source for the request data (default: '/boson/fn')"`
Type *string `json:"type,omitempty" jsonschema:"CloudEvent type for the request data (default: 'boson.fn')"`
Data *string `json:"data,omitempty" jsonschema:"Data (content) to send in the request. If both this and 'file' are omitted, a default payload of '{\"message\":\"Hello World\"}' is sent"`
ContentType *string `json:"contentType,omitempty" jsonschema:"MIME type of the data (default: 'application/json')"`
RequestType *string `json:"requestType,omitempty" jsonschema:"HTTP method: GET or POST (default: POST)"`
File *string `json:"file,omitempty" jsonschema:"Path to a file whose content is used as the request data (overrides data). MUST be an absolute path: it is resolved relative to the MCP server's process working directory, not the caller's or the 'path' parameter's directory"`
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"`
}
236 changes: 236 additions & 0 deletions pkg/mcp/tools_invoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
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 remains available in
// readonly mode, since invoking a Function does not itself mutate cluster
// state (unlike deploy, delete, and build, which readonly mode disables).
func TestTool_Invoke_Readonly(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("OK\n"), nil
}

client, _, err := newTestPairCore(t, true, WithReadonly(true), 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("expected invoke to succeed in readonly mode, got error: %v", result)
}
if !executor.ExecuteInvoked {
t.Fatal("executor was not invoked")
}
}

// 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