diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index b43715ee34..0df67875d5 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) @@ -138,6 +139,10 @@ func New(options ...Option) *Server { i.AddResource(newHelpResource(s, "Envs Add Help", "help for 'config envs add'", "config", "envs", "add")) i.AddResource(newHelpResource(s, "Envs Remove Help", "help for 'config envs remove'", "config", "envs", "remove")) + // Prompts + // ------- + i.AddPrompt(funcWorkflowPrompt, funcWorkflowHandler) + s.impl = i return s diff --git a/pkg/mcp/prompts.go b/pkg/mcp/prompts.go new file mode 100644 index 0000000000..8cc5a940f1 --- /dev/null +++ b/pkg/mcp/prompts.go @@ -0,0 +1,53 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var funcWorkflowPrompt = &mcp.Prompt{ + Name: "func-workflow", + Title: "Function Lifecycle Workflow", + Description: "Guides through the full Function lifecycle: create, build, and deploy.", + Arguments: []*mcp.PromptArgument{ + { + Name: "language", + Description: "Programming language for the Function (e.g. go, python, node).", + }, + }, +} + +func funcWorkflowHandler(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + lang := req.Params.Arguments["language"] + return &mcp.GetPromptResult{ + Description: "Step-by-step guide for the Function lifecycle.", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: funcWorkflowText(lang)}, + }, + }, + }, nil +} + +func funcWorkflowText(language string) string { + langLine := "" + if language != "" { + langLine = fmt.Sprintf("\nUse language: %s\n", language) + } + return fmt.Sprintf(`Guide me through the full Function lifecycle.%s +Step 1 - Create: + Use the "create" tool to scaffold a new Function. + Check "func://languages" for available languages and "func://templates" for templates. + +Step 2 - Build: + Use the "build" tool to compile the Function into a container image. + +Step 3 - Deploy: + Use the "deploy" tool to deploy the Function to the cluster. + On first deploy, a container registry is required (e.g. docker.io/user). + +Use "func://function" to check current Function state at any point.`, langLine) +} diff --git a/pkg/mcp/prompts_test.go b/pkg/mcp/prompts_test.go new file mode 100644 index 0000000000..105fb5f5ea --- /dev/null +++ b/pkg/mcp/prompts_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestPrompt_Listed(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + + result, err := client.ListPrompts(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + + var found bool + for _, p := range result.Prompts { + if p.Name == "func-workflow" { + found = true + if p.Description == "" { + t.Error("expected non-empty description") + } + break + } + } + if !found { + t.Fatal("func-workflow prompt not found in listing") + } +} + +func TestPrompt_Get(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + + result, err := client.GetPrompt(t.Context(), &mcp.GetPromptParams{ + Name: "func-workflow", + }) + if err != nil { + t.Fatal(err) + } + + if len(result.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(result.Messages)) + } + msg := result.Messages[0] + if msg.Role != "user" { + t.Fatalf("expected role 'user', got %q", msg.Role) + } + tc, ok := msg.Content.(*mcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", msg.Content) + } + if !strings.Contains(tc.Text, "create") { + t.Error("expected prompt text to mention create") + } + if !strings.Contains(tc.Text, "deploy") { + t.Error("expected prompt text to mention deploy") + } +} + +func TestPrompt_GetWithLanguage(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + + result, err := client.GetPrompt(t.Context(), &mcp.GetPromptParams{ + Name: "func-workflow", + Arguments: map[string]string{"language": "go"}, + }) + if err != nil { + t.Fatal(err) + } + + if len(result.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(result.Messages)) + } + tc, ok := result.Messages[0].Content.(*mcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", result.Messages[0].Content) + } + if !strings.Contains(tc.Text, "go") { + t.Error("expected prompt text to contain the language 'go'") + } +} 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") + } +}