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