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
77 changes: 77 additions & 0 deletions pkg/mcp/tools_describe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package mcp

import (
"context"
"fmt"

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

var describeTool = &mcp.Tool{
Name: "describe",
Title: "Describe Function",
Description: "Print the name, image, namespace, routes, and event subscriptions for a deployed function. Accepts either a local directory path or a function name.",
Annotations: &mcp.ToolAnnotations{
Title: "Describe Function",
ReadOnlyHint: true,
DestructiveHint: ptr(false),
IdempotentHint: true, // Describe has no side effects regardless of how many times it is called.
},
}

func (s *Server) describeHandler(ctx context.Context, r *mcp.CallToolRequest, input DescribeInput) (result *mcp.CallToolResult, output DescribeOutput, err error) {
pathSet := input.Path != nil && *input.Path != ""
nameSet := input.Name != nil && *input.Name != ""

if pathSet && nameSet {
err = fmt.Errorf("'path' and 'name' are mutually exclusive: provide one or the other")
return
}
if input.Namespace != nil && *input.Namespace != "" && !nameSet {
err = fmt.Errorf("'namespace' requires 'name' to also be provided")
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.
// At most one of Path or Name should be provided; if neither is given, the
// function in the current working directory is described.
type DescribeInput struct {
// Path and Name are mutually exclusive. Namespace is only valid with Name.
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 function to describe (mutually exclusive with path)"`
Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace (only used together with name)"`
Output *string `json:"output,omitempty" jsonschema:"Output format: human, plain, json, yaml, or url"`
Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"`
}

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

// Name is a positional argument; path is a flag.
if i.Name != nil && *i.Name != "" {
args = append(args, *i.Name)
} else if i.Path != nil && *i.Path != "" {
args = append(args, "--path", *i.Path)
}

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 from func describe"`
}
203 changes: 203 additions & 0 deletions pkg/mcp/tools_describe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package mcp

import (
"context"
"testing"

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

// TestTool_Describe_ByPath ensures the describe tool passes --path and optional flags correctly.
func TestTool_Describe_ByPath(t *testing.T) {
stringFlags := map[string]struct {
jsonKey string
flag string
value string
}{
"path": {"path", "--path", "./my-func"},
"output": {"output", "--output", "json"},
}

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

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)
}
validateArgLength(t, args, len(stringFlags), len(boolFlags))
validateStringFlags(t, args, stringFlags)
validateBoolFlags(t, args, boolFlags)
return []byte("Function name:\n my-func\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: "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_ByName ensures the describe tool passes the function name as a positional argument.
func TestTool_Describe_ByName(t *testing.T) {
stringFlags := map[string]struct {
jsonKey string
flag string
value string
}{
"namespace": {"namespace", "--namespace", "prod"},
}

boolFlags := map[string]string{}

name := "my-func"

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 + 1 string flag * 2 + 0 bool flags = 3 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 name argument comes first
if args[0] != name {
t.Fatalf("expected positional arg %q, got %q", name, args[0])
}

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

return []byte("Function name:\n my-func\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_NoArgs ensures the describe tool works with no arguments,
// falling back to describing the function in the current working directory.
func TestTool_Describe_NoArgs(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 no args for current-directory describe, got %d: %v", len(args), args)
}
return []byte("Function name:\n my-func\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_PathAndNameConflict ensures an error is returned when both path and name are provided.
func TestTool_Describe_PathAndNameConflict(t *testing.T) {
executor := mock.NewExecutor()

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{
"path": "./my-func",
"name": "my-func",
},
})
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 have been invoked")
}
}

// TestTool_Describe_NamespaceWithoutName ensures an error is returned when namespace is set without a name.
func TestTool_Describe_NamespaceWithoutName(t *testing.T) {
executor := mock.NewExecutor()

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{
"namespace": "prod",
},
})
if err != nil {
t.Fatal(err)
}
if !result.IsError {
t.Fatal("expected error result when namespace is provided without name")
}
if executor.ExecuteInvoked {
t.Fatal("executor should not have been invoked")
}
}
Loading