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 @@ -95,6 +95,7 @@ func New(options ...Option) *Server {
mcp.AddTool(i, buildTool, s.buildHandler)
mcp.AddTool(i, deployTool, s.deployHandler)
mcp.AddTool(i, listTool, s.listHandler)
mcp.AddTool(i, describeTool, s.describeHandler)
mcp.AddTool(i, deleteTool, s.deleteHandler)
mcp.AddTool(i, configVolumesListTool, s.configVolumesListHandler)
mcp.AddTool(i, configVolumesAddTool, s.configVolumesAddHandler)
Expand Down
69 changes: 69 additions & 0 deletions pkg/mcp/tools_describe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package mcp

import (
"context"
"fmt"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

var describeTool = &mcp.Tool{
Name: "describe",
Title: "Describe Function",
Description: "Show the name, route, subscriptions (extractors), and other details of a deployed function.",
Annotations: &mcp.ToolAnnotations{
Title: "Describe Function",
ReadOnlyHint: true,
IdempotentHint: true,
},
}

func (s *Server) describeHandler(ctx context.Context, r *mcp.CallToolRequest, input DescribeInput) (result *mcp.CallToolResult, output DescribeOutput, err error) {
// Validate: path and name are mutually exclusive
if input.Path != nil && input.Name != nil {
err = fmt.Errorf("'path' and 'name' are mutually exclusive")
return
}

out, err := s.executor.Execute(ctx, "describe", input.Args()...)
if err != nil {
err = fmt.Errorf("%w\n%s", err, string(out))
return
}
output = DescribeOutput{
Message: string(out),
}
return
}

// DescribeInput defines the input parameters for the describe tool.
// Path and Name are mutually exclusive. If neither is provided, the function
// in the current directory is described.
type DescribeInput struct {
Path *string `json:"path,omitempty" jsonschema:"Path to the function project directory (mutually exclusive with name)"`
Name *string `json:"name,omitempty" jsonschema:"Name of the deployed function to describe (mutually exclusive with path)"`
Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace of the function (default: current namespace)"`
Output *string `json:"output,omitempty" jsonschema:"Output format: human, plain, json, xml, or yaml"`
Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"`
}

func (i DescribeInput) Args() []string {
args := []string{}

// Either path flag or positional name argument
if i.Path != nil {
args = append(args, "--path", *i.Path)
} else if i.Name != nil {
args = append(args, *i.Name)
}

args = appendStringFlag(args, "--namespace", i.Namespace)
args = appendStringFlag(args, "--output", i.Output)
args = appendBoolFlag(args, "--verbose", i.Verbose)
return args
}

// DescribeOutput defines the structured output returned by the describe tool.
type DescribeOutput struct {
Message string `json:"message" jsonschema:"Output message"`
}
206 changes: 206 additions & 0 deletions pkg/mcp/tools_describe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package mcp

import (
"context"
"fmt"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"knative.dev/func/pkg/mcp/mock"
)

// TestTool_Describe_Args ensures the describe tool executes with all arguments passed correctly
// when using a name (positional argument).
func TestTool_Describe_Args(t *testing.T) {
stringFlags := map[string]struct {
jsonKey string
flag string
value string
}{
"namespace": {"namespace", "--namespace", "prod"},
"output": {"output", "--output", "json"},
}

boolFlags := map[string]string{
"verbose": "--verbose",
}

name := "my-function"

executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
if subcommand != "describe" {
t.Fatalf("expected subcommand 'describe', got %q", subcommand)
}

// Expected: 1 positional + 2 string flags + 1 bool flag = 1 + 2*2 + 1 = 6 args
if len(args) != 1+len(stringFlags)*2+len(boolFlags) {
t.Fatalf("expected %d args, got %d: %v", 1+len(stringFlags)*2+len(boolFlags), len(args), args)
}

// Validate positional argument (name) comes first
if args[0] != name {
t.Fatalf("expected positional arg %q, got %q", name, args[0])
}

// Validate flags
validateStringFlags(t, args[1:], stringFlags)
validateBoolFlags(t, args[1:], boolFlags)

return []byte("Function 'my-function' in namespace 'prod'\n"), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

inputArgs := buildInputArgs(stringFlags, boolFlags)
inputArgs["name"] = name

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe",
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_Describe_PathBased ensures the describe tool passes --path flag correctly.
func TestTool_Describe_PathBased(t *testing.T) {
path := "/home/user/my-project"

executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
if subcommand != "describe" {
t.Fatalf("expected subcommand 'describe', got %q", subcommand)
}

// Expected: --path /home/user/my-project = 2 args
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
if args[0] != "--path" || args[1] != path {
t.Fatalf("expected '--path %s', got %v", path, args)
}

return []byte("Function details\n"), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

inputArgs := map[string]any{
"path": path,
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe",
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_Describe_MutualExclusion ensures that providing both path and name returns an error.
func TestTool_Describe_MutualExclusion(t *testing.T) {
executor := mock.NewExecutor()

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

inputArgs := map[string]any{
"path": "/some/path",
"name": "my-function",
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe",
Arguments: inputArgs,
})
if err != nil {
t.Fatal(err)
}
if !result.IsError {
t.Fatal("expected error result when both path and name are provided")
}
if executor.ExecuteInvoked {
t.Fatal("executor should not be invoked when validation fails")
}
}

// TestTool_Describe_NeitherProvided ensures the describe tool works with no path or name,
// describing the function in the current working directory.
func TestTool_Describe_NeitherProvided(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
if subcommand != "describe" {
t.Fatalf("expected subcommand 'describe', got %q", subcommand)
}
if len(args) != 0 {
t.Fatalf("expected 0 args, got %d: %v", len(args), args)
}
return []byte("Function details\n"), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe",
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_Describe_BinaryFailure ensures that executor errors are propagated correctly.
func TestTool_Describe_BinaryFailure(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("error output"), fmt.Errorf("command failed")
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if !result.IsError {
t.Fatal("expected error result when executor fails")
}
}