From eeb129c711795ebcfc85d56fef547a2b6be9dc3d Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Sat, 16 May 2026 20:53:51 +0530 Subject: [PATCH] mcp: add repository management tools and corresponding tests This commit introduces new tools for managing template repositories, including listing, adding, renaming, and removing repositories. Each tool is accompanied by a handler in the MCP server and relevant input/output structures. Additionally, comprehensive tests have been added to ensure the correct functionality of these tools, including error handling and readonly mode checks. --- pkg/mcp/mcp.go | 9 + pkg/mcp/tools_repository.go | 176 +++++++++++++++ pkg/mcp/tools_repository_test.go | 370 +++++++++++++++++++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 pkg/mcp/tools_repository.go create mode 100644 pkg/mcp/tools_repository_test.go diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index a0c14496e2..4282b99075 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -105,6 +105,10 @@ func New(options ...Option) *Server { mcp.AddTool(i, configEnvsListTool, s.configEnvsListHandler) mcp.AddTool(i, configEnvsAddTool, s.configEnvsAddHandler) mcp.AddTool(i, configEnvsRemoveTool, s.configEnvsRemoveHandler) + mcp.AddTool(i, repositoryListTool, s.repositoryListHandler) + mcp.AddTool(i, repositoryAddTool, s.repositoryAddHandler) + mcp.AddTool(i, repositoryRenameTool, s.repositoryRenameHandler) + mcp.AddTool(i, repositoryRemoveTool, s.repositoryRemoveHandler) // Resources // --------- @@ -139,6 +143,11 @@ 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")) + i.AddResource(newHelpResource(s, "Repository Help", "general help for repository management", "repository")) + i.AddResource(newHelpResource(s, "Repository Add Help", "help for 'repository add'", "repository", "add")) + i.AddResource(newHelpResource(s, "Repository Rename Help", "help for 'repository rename'", "repository", "rename")) + i.AddResource(newHelpResource(s, "Repository Remove Help", "help for 'repository remove'", "repository", "remove")) + s.impl = i return s diff --git a/pkg/mcp/tools_repository.go b/pkg/mcp/tools_repository.go new file mode 100644 index 0000000000..e586d405f5 --- /dev/null +++ b/pkg/mcp/tools_repository.go @@ -0,0 +1,176 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// repository_list + +var repositoryListTool = &mcp.Tool{ + Name: "repository_list", + Title: "Repository List", + Description: "Lists all installed template repositories, including the default embedded repository.", + Annotations: &mcp.ToolAnnotations{ + Title: "Repository List", + ReadOnlyHint: true, + IdempotentHint: true, + }, +} + +type RepositoryListInput struct { + Verbose *bool `json:"verbose,omitempty" jsonschema:"Show the URL of each remote repository"` +} + +func (i RepositoryListInput) Args() []string { + args := []string{"list"} + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +type RepositoryListOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} + +func (s *Server) repositoryListHandler(ctx context.Context, _ *mcp.CallToolRequest, input RepositoryListInput) (result *mcp.CallToolResult, output RepositoryListOutput, err error) { + out, err := s.executor.Execute(ctx, "repository", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = RepositoryListOutput{Message: string(out)} + return +} + +// repository_add + +var repositoryAddTool = &mcp.Tool{ + Name: "repository_add", + Title: "Repository Add", + Description: "Adds a named template repository by URL so that its templates become available when creating new functions.", + Annotations: &mcp.ToolAnnotations{ + Title: "Repository Add", + ReadOnlyHint: false, + DestructiveHint: ptr(false), // additive only; does not overwrite existing repositories + IdempotentHint: false, // adding the same name twice will fail + }, +} + +type RepositoryAddInput struct { + Name string `json:"name" jsonschema:"required,Short name to assign to the repository (e.g. 'boson')"` + URL string `json:"url" jsonschema:"required,URL of the git repository to add (may include a branch suffix '#branch')"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i RepositoryAddInput) Args() []string { + args := []string{"add", i.Name, i.URL} + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +type RepositoryAddOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} + +func (s *Server) repositoryAddHandler(ctx context.Context, _ *mcp.CallToolRequest, input RepositoryAddInput) (result *mcp.CallToolResult, output RepositoryAddOutput, err error) { + if s.readonly { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + out, err := s.executor.Execute(ctx, "repository", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = RepositoryAddOutput{Message: string(out)} + return +} + +// repository_rename + +var repositoryRenameTool = &mcp.Tool{ + Name: "repository_rename", + Title: "Repository Rename", + Description: "Renames a previously installed template repository from one name to another.", + Annotations: &mcp.ToolAnnotations{ + Title: "Repository Rename", + ReadOnlyHint: false, + DestructiveHint: ptr(false), // mutates metadata only; no data is deleted + IdempotentHint: false, + }, +} + +type RepositoryRenameInput struct { + Old string `json:"old" jsonschema:"required,Current name of the repository to rename"` + New string `json:"new" jsonschema:"required,New name for the repository"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i RepositoryRenameInput) Args() []string { + args := []string{"rename", i.Old, i.New} + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +type RepositoryRenameOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} + +func (s *Server) repositoryRenameHandler(ctx context.Context, _ *mcp.CallToolRequest, input RepositoryRenameInput) (result *mcp.CallToolResult, output RepositoryRenameOutput, err error) { + if s.readonly { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + out, err := s.executor.Execute(ctx, "repository", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = RepositoryRenameOutput{Message: string(out)} + return +} + +// repository_remove + +var repositoryRemoveTool = &mcp.Tool{ + Name: "repository_remove", + Title: "Repository Remove", + Description: "Removes an installed template repository from local disk by name. The default embedded repository cannot be removed.", + Annotations: &mcp.ToolAnnotations{ + Title: "Repository Remove", + ReadOnlyHint: false, + DestructiveHint: ptr(true), // removes repository from local disk permanently + IdempotentHint: false, // removing a non-existent repository will fail + }, +} + +type RepositoryRemoveInput struct { + Name string `json:"name" jsonschema:"required,Name of the installed repository to remove"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i RepositoryRemoveInput) Args() []string { + args := []string{"remove", i.Name} + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +type RepositoryRemoveOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} + +func (s *Server) repositoryRemoveHandler(ctx context.Context, _ *mcp.CallToolRequest, input RepositoryRemoveInput) (result *mcp.CallToolResult, output RepositoryRemoveOutput, err error) { + if s.readonly { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + out, err := s.executor.Execute(ctx, "repository", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = RepositoryRemoveOutput{Message: string(out)} + return +} diff --git a/pkg/mcp/tools_repository_test.go b/pkg/mcp/tools_repository_test.go new file mode 100644 index 0000000000..4acbaecd92 --- /dev/null +++ b/pkg/mcp/tools_repository_test.go @@ -0,0 +1,370 @@ +package mcp + +import ( + "context" + "errors" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_RepositoryList ensures the repository_list tool invokes the executor correctly. +func TestTool_RepositoryList(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "repository" { + t.Fatalf("expected subcommand 'repository', got %q", subcommand) + } + if len(args) < 1 || args[0] != "list" { + t.Fatalf("expected args[0]='list', got %v", args) + } + return []byte("default\nboson\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_list", + 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_RepositoryList_Verbose ensures --verbose is passed when requested. +func TestTool_RepositoryList_Verbose(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + argsMap := argsToMap(args) + if _, ok := argsMap["--verbose"]; !ok { + t.Fatalf("expected --verbose flag, got args: %v", args) + } + return []byte("default\nboson\thttps://github.com/boson-project/templates\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_list", + Arguments: map[string]any{"verbose": true}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } +} + +// TestTool_RepositoryList_Error ensures executor errors are propagated. +func TestTool_RepositoryList_Error(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte("list failed"), errors.New("executor error") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_list", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result, got success") + } +} + +// TestTool_RepositoryAdd ensures the repository_add tool passes name and URL correctly. +func TestTool_RepositoryAdd(t *testing.T) { + const ( + repoName = "boson" + repoURL = "https://github.com/boson-project/templates" + ) + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "repository" { + t.Fatalf("expected subcommand 'repository', got %q", subcommand) + } + // args: ["add", , ] + if len(args) < 3 { + t.Fatalf("expected at least 3 args (add ), got %d: %v", len(args), args) + } + if args[0] != "add" { + t.Fatalf("expected args[0]='add', got %q", args[0]) + } + if args[1] != repoName { + t.Fatalf("expected args[1]=%q, got %q", repoName, args[1]) + } + if args[2] != repoURL { + t.Fatalf("expected args[2]=%q, got %q", repoURL, args[2]) + } + return []byte(""), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_add", + Arguments: map[string]any{"name": repoName, "url": repoURL}, + }) + 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_RepositoryAdd_Readonly ensures repository_add is blocked in readonly mode. +func TestTool_RepositoryAdd_Readonly(t *testing.T) { + executor := mock.NewExecutor() + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly = true + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_add", + Arguments: map[string]any{"name": "boson", "url": "https://github.com/boson-project/templates"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if executor.ExecuteInvoked { + t.Fatal("executor should not have been invoked in readonly mode") + } +} + +// TestTool_RepositoryAdd_Error ensures executor errors are propagated. +func TestTool_RepositoryAdd_Error(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte("add failed"), errors.New("executor error") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_add", + Arguments: map[string]any{"name": "boson", "url": "https://github.com/boson-project/templates"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result, got success") + } +} + +// TestTool_RepositoryRename ensures the repository_rename tool passes old and new names correctly. +func TestTool_RepositoryRename(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "repository" { + t.Fatalf("expected subcommand 'repository', got %q", subcommand) + } + // args: ["rename", , ] + if len(args) < 3 { + t.Fatalf("expected at least 3 args (rename ), got %d: %v", len(args), args) + } + if args[0] != "rename" { + t.Fatalf("expected args[0]='rename', got %q", args[0]) + } + if args[1] != "boson" { + t.Fatalf("expected args[1]='boson', got %q", args[1]) + } + if args[2] != "functastic" { + t.Fatalf("expected args[2]='functastic', got %q", args[2]) + } + return []byte(""), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_rename", + Arguments: map[string]any{"old": "boson", "new": "functastic"}, + }) + 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_RepositoryRename_Readonly ensures repository_rename is blocked in readonly mode. +func TestTool_RepositoryRename_Readonly(t *testing.T) { + executor := mock.NewExecutor() + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly = true + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_rename", + Arguments: map[string]any{"old": "boson", "new": "functastic"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if executor.ExecuteInvoked { + t.Fatal("executor should not have been invoked in readonly mode") + } +} + +// TestTool_RepositoryRename_Error ensures executor errors are propagated. +func TestTool_RepositoryRename_Error(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte("rename failed"), errors.New("executor error") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_rename", + Arguments: map[string]any{"old": "boson", "new": "functastic"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result, got success") + } +} + +// TestTool_RepositoryRemove ensures the repository_remove tool passes the name correctly. +func TestTool_RepositoryRemove(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "repository" { + t.Fatalf("expected subcommand 'repository', got %q", subcommand) + } + // args: ["remove", ] + if len(args) < 2 { + t.Fatalf("expected at least 2 args (remove ), got %d: %v", len(args), args) + } + if args[0] != "remove" { + t.Fatalf("expected args[0]='remove', got %q", args[0]) + } + if args[1] != "boson" { + t.Fatalf("expected args[1]='boson', got %q", args[1]) + } + return []byte(""), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_remove", + Arguments: map[string]any{"name": "boson"}, + }) + 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_RepositoryRemove_Readonly ensures repository_remove is blocked in readonly mode. +func TestTool_RepositoryRemove_Readonly(t *testing.T) { + executor := mock.NewExecutor() + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly = true + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_remove", + Arguments: map[string]any{"name": "boson"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if executor.ExecuteInvoked { + t.Fatal("executor should not have been invoked in readonly mode") + } +} + +// TestTool_RepositoryRemove_Error ensures executor errors are propagated. +func TestTool_RepositoryRemove_Error(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + return []byte("remove failed"), errors.New("executor error") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "repository_remove", + Arguments: map[string]any{"name": "boson"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result, got success") + } +}