From 704b5f5a6fe5d65a0a94e6bff13029dd2799de45 Mon Sep 17 00:00:00 2001 From: Thonmay Date: Thu, 14 May 2026 22:47:04 +0200 Subject: [PATCH] mcp: add describe tool for Function inspection Adds a new MCP tool 'describe' that exposes the 'func describe' command to agents. The tool accepts an optional name (positional) or --path flag (mutually exclusive), plus --namespace, --output, and --verbose flags. When neither path nor name is provided, the function in the current working directory is described. Fixes #3729 Signed-off-by: Thonmay --- pkg/mcp/mcp.go | 1 + pkg/mcp/tools_describe.go | 69 +++++++++++ pkg/mcp/tools_describe_test.go | 206 +++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 pkg/mcp/tools_describe.go create mode 100644 pkg/mcp/tools_describe_test.go diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index b43715ee34..ac13d64deb 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -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) diff --git a/pkg/mcp/tools_describe.go b/pkg/mcp/tools_describe.go new file mode 100644 index 0000000000..17e56295d1 --- /dev/null +++ b/pkg/mcp/tools_describe.go @@ -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"` +} diff --git a/pkg/mcp/tools_describe_test.go b/pkg/mcp/tools_describe_test.go new file mode 100644 index 0000000000..373eb4e4d7 --- /dev/null +++ b/pkg/mcp/tools_describe_test.go @@ -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") + } +}