diff --git a/pkg/mcp/errors.go b/pkg/mcp/errors.go new file mode 100644 index 0000000000..7c25a77604 --- /dev/null +++ b/pkg/mcp/errors.go @@ -0,0 +1,5 @@ +package mcp + +import "errors" + +var errReadOnlyMode = errors.New("the server is currently in read-only mode; to enable write operations, set FUNC_ENABLE_MCP_WRITE in the server environment and restart the server") diff --git a/pkg/mcp/instructions_warning.md b/pkg/mcp/instructions_warning.md index 98697ddeec..3c98d68c64 100644 --- a/pkg/mcp/instructions_warning.md +++ b/pkg/mcp/instructions_warning.md @@ -8,19 +8,20 @@ The Functions MCP server is currently running in **read-only mode**. **Available operations:** - Create Functions -- Build Functions -- Configure Functions (envs, labels, volumes) - Inspect Functions +- List Function configuration **Disabled operations:** +- Build Function images +- Configure Functions (envs, labels, volumes add/remove) - Deploy to cluster - Delete from cluster -These write operations are disabled to prevent unintended cluster modifications. +These write operations are disabled to prevent unintended local or cluster modifications. ## Enabling Write Mode -If the user needs to deploy or delete Functions, you MUST inform them to enable write mode: +If the user needs to mutate Functions, you MUST inform them to enable write mode: 1. Close/exit this application completely 2. Set the environment variable: `FUNC_ENABLE_MCP_WRITE=true` diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 02ea0f5d44..7f38ca88e6 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -24,7 +24,7 @@ const ( type Server struct { OnInit func(context.Context) // Invoked when the server is initialized prefix string // Command prefix ("func" or "kn func") - readonly atomic.Bool // disables deploy and delete when true + readonly atomic.Bool // disables mutation tools when true executor executor transport mcp.Transport // Transport to use (defaults to StdioTransport) impl *mcp.Server // implements the protocol diff --git a/pkg/mcp/tools_build.go b/pkg/mcp/tools_build.go index e1f6a6758f..4200b534e6 100644 --- a/pkg/mcp/tools_build.go +++ b/pkg/mcp/tools_build.go @@ -21,7 +21,7 @@ var buildTool = &mcp.Tool{ func (s *Server) buildHandler(ctx context.Context, r *mcp.CallToolRequest, input BuildInput) (result *mcp.CallToolResult, output BuildOutput, err error) { if s.readonly.Load() { - err = fmt.Errorf("the server is currently in read-only mode; to enable write operations, set FUNC_ENABLE_MCP_WRITE in the server environment and restart the server") + err = errReadOnlyMode return } diff --git a/pkg/mcp/tools_config_envs.go b/pkg/mcp/tools_config_envs.go index 397b8c1eef..fb96d3cdc5 100644 --- a/pkg/mcp/tools_config_envs.go +++ b/pkg/mcp/tools_config_envs.go @@ -142,6 +142,10 @@ type ConfigEnvsAddOutput struct { } func (s *Server) configEnvsAddHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigEnvsAddInput) (result *mcp.CallToolResult, output ConfigEnvsAddOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } if err = input.validate(); err != nil { return } @@ -186,6 +190,10 @@ type ConfigEnvsRemoveOutput struct { } func (s *Server) configEnvsRemoveHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigEnvsRemoveInput) (result *mcp.CallToolResult, output ConfigEnvsRemoveOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_config_envs_test.go b/pkg/mcp/tools_config_envs_test.go index 327ab06f9a..21f3a955e8 100644 --- a/pkg/mcp/tools_config_envs_test.go +++ b/pkg/mcp/tools_config_envs_test.go @@ -627,3 +627,59 @@ func TestTool_ConfigEnvsRemove_Error(t *testing.T) { t.Fatal("expected error result, got success") } } + +// TestTool_ConfigEnvsAdd_Readonly ensures the config_envs_add tool is blocked in readonly mode. +func TestTool_ConfigEnvsAdd_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_envs_add", + Arguments: map[string]any{"path": ".", "name": "KEY", "value": "VAL"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} + +// TestTool_ConfigEnvsRemove_Readonly ensures the config_envs_remove tool is blocked in readonly mode. +func TestTool_ConfigEnvsRemove_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_envs_remove", + Arguments: map[string]any{"path": ".", "name": "KEY"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} diff --git a/pkg/mcp/tools_config_labels.go b/pkg/mcp/tools_config_labels.go index 0c62e87290..9003aa9114 100644 --- a/pkg/mcp/tools_config_labels.go +++ b/pkg/mcp/tools_config_labels.go @@ -79,6 +79,10 @@ type ConfigLabelsAddOutput struct { } func (s *Server) configLabelsAddHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigLabelsAddInput) (result *mcp.CallToolResult, output ConfigLabelsAddOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -120,6 +124,10 @@ type ConfigLabelsRemoveOutput struct { } func (s *Server) configLabelsRemoveHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigLabelsRemoveInput) (result *mcp.CallToolResult, output ConfigLabelsRemoveOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_config_labels_test.go b/pkg/mcp/tools_config_labels_test.go index e3d2a42c83..8c709488c1 100644 --- a/pkg/mcp/tools_config_labels_test.go +++ b/pkg/mcp/tools_config_labels_test.go @@ -242,3 +242,59 @@ func TestTool_ConfigLabelsRemove_Error(t *testing.T) { t.Fatal("expected error result, got success") } } + +// TestTool_ConfigLabelsAdd_Readonly ensures the config_labels_add tool is blocked in readonly mode. +func TestTool_ConfigLabelsAdd_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_labels_add", + Arguments: map[string]any{"path": ".", "name": "app", "value": "test"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} + +// TestTool_ConfigLabelsRemove_Readonly ensures the config_labels_remove tool is blocked in readonly mode. +func TestTool_ConfigLabelsRemove_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_labels_remove", + Arguments: map[string]any{"path": ".", "name": "app"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} diff --git a/pkg/mcp/tools_config_volumes.go b/pkg/mcp/tools_config_volumes.go index 232d866d30..b3475a3d4f 100644 --- a/pkg/mcp/tools_config_volumes.go +++ b/pkg/mcp/tools_config_volumes.go @@ -87,6 +87,10 @@ type ConfigVolumesAddOutput struct { } func (s *Server) configVolumesAddHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigVolumesAddInput) (result *mcp.CallToolResult, output ConfigVolumesAddOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -128,6 +132,10 @@ type ConfigVolumesRemoveOutput struct { } func (s *Server) configVolumesRemoveHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigVolumesRemoveInput) (result *mcp.CallToolResult, output ConfigVolumesRemoveOutput, err error) { + if s.readonly.Load() { + err = errReadOnlyMode + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_config_volumes_test.go b/pkg/mcp/tools_config_volumes_test.go index bc10965554..9623fdeacc 100644 --- a/pkg/mcp/tools_config_volumes_test.go +++ b/pkg/mcp/tools_config_volumes_test.go @@ -246,3 +246,59 @@ func TestTool_ConfigVolumesRemove_Error(t *testing.T) { t.Fatal("expected error result, got success") } } + +// TestTool_ConfigVolumesAdd_Readonly ensures the config_volumes_add tool is blocked in readonly mode. +func TestTool_ConfigVolumesAdd_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_volumes_add", + Arguments: map[string]any{"path": "."}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} + +// TestTool_ConfigVolumesRemove_Readonly ensures the config_volumes_remove tool is blocked in readonly mode. +func TestTool_ConfigVolumesRemove_Readonly(t *testing.T) { + executor := mock.NewExecutor() + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + server.readonly.Store(true) + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_volumes_remove", + Arguments: map[string]any{"path": "."}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected error result in readonly mode, got success") + } + if got := resultToString(result); got != errReadOnlyMode.Error() { + t.Errorf("expected readonly error message %q, got %q", errReadOnlyMode.Error(), got) + } + if executor.ExecuteInvoked { + t.Fatal("executor should not be invoked in readonly mode") + } +} diff --git a/pkg/mcp/tools_delete.go b/pkg/mcp/tools_delete.go index 27760f864d..81d3ead88f 100644 --- a/pkg/mcp/tools_delete.go +++ b/pkg/mcp/tools_delete.go @@ -21,7 +21,7 @@ var deleteTool = &mcp.Tool{ func (s *Server) deleteHandler(ctx context.Context, r *mcp.CallToolRequest, input DeleteInput) (result *mcp.CallToolResult, output DeleteOutput, err error) { if s.readonly.Load() { - err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + err = errReadOnlyMode return } diff --git a/pkg/mcp/tools_deploy.go b/pkg/mcp/tools_deploy.go index 09c7231656..fdb6a224b5 100644 --- a/pkg/mcp/tools_deploy.go +++ b/pkg/mcp/tools_deploy.go @@ -21,7 +21,7 @@ var deployTool = &mcp.Tool{ func (s *Server) deployHandler(ctx context.Context, r *mcp.CallToolRequest, input DeployInput) (result *mcp.CallToolResult, output DeployOutput, err error) { if s.readonly.Load() { - err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + err = errReadOnlyMode return } out, err := s.executor.Execute(ctx, "deploy", input.Args()...)