diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index faea25f0d7..7c371c098f 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -2,6 +2,7 @@ package mcp import ( "fmt" + "path/filepath" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -44,3 +45,12 @@ func appendBoolFlag(args []string, flag string, value *bool) []string { func ptr[T any](v T) *T { return &v } + +// validatePath rejects relative paths. Since cmd.Dir is not set in the executor, +// relative paths resolve against the MCP server's CWD, not the user's project root. +func validatePath(path string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("path must be absolute (the MCP server's working directory is not the user's project root), got %q", path) + } + return nil +} diff --git a/pkg/mcp/tools_build.go b/pkg/mcp/tools_build.go index 1332dbc497..e26d5ba393 100644 --- a/pkg/mcp/tools_build.go +++ b/pkg/mcp/tools_build.go @@ -24,6 +24,9 @@ func (s *Server) buildHandler(ctx context.Context, r *mcp.CallToolRequest, input err = fmt.Errorf("pushing images is not allowed in read-only mode; set %s=true to enable write operations", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "build", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_build_test.go b/pkg/mcp/tools_build_test.go index 56453835ec..06aeb6ec0e 100644 --- a/pkg/mcp/tools_build_test.go +++ b/pkg/mcp/tools_build_test.go @@ -44,7 +44,7 @@ func TestTool_Build_ReadonlyWithoutPushAllowed(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "build", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -60,12 +60,13 @@ func TestTool_Build_ReadonlyWithoutPushAllowed(t *testing.T) { // TestTool_Build_Args ensures the build tool executes with all arguments passed correctly. func TestTool_Build_Args(t *testing.T) { // Test data - defined once and used for both input and validation + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "builder": {"builder", "--builder", "pack"}, "registry": {"registry", "--registry", "ghcr.io/user"}, "builderImage": {"builderImage", "--builder-image", "custom-builder:latest"}, diff --git a/pkg/mcp/tools_config_ci.go b/pkg/mcp/tools_config_ci.go index a10b5fb098..781598a928 100644 --- a/pkg/mcp/tools_config_ci.go +++ b/pkg/mcp/tools_config_ci.go @@ -67,6 +67,9 @@ type ConfigCIOutput struct { } func (s *Server) configCIHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigCIInput) (result *mcp.CallToolResult, output ConfigCIOutput, err error) { + if err = validatePath(input.Path); err != nil { + 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_ci_test.go b/pkg/mcp/tools_config_ci_test.go index 201d81f7e9..49de7dcfc8 100644 --- a/pkg/mcp/tools_config_ci_test.go +++ b/pkg/mcp/tools_config_ci_test.go @@ -11,12 +11,13 @@ import ( // TestTool_ConfigCI ensures the config_ci tool executes with all arguments forwarded. func TestTool_ConfigCI(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "branch": {"branch", "--branch", "main"}, "workflowName": {"workflowName", "--workflow-name", "Func Deploy"}, "kubeconfigSecretName": {"kubeconfigSecretName", "--kubeconfig-secret-name", "KUBECONFIG"}, @@ -82,13 +83,14 @@ func TestTool_ConfigCI(t *testing.T) { // TestTool_ConfigCI_MinimalArgs ensures the config_ci tool executes with only the // required 'path' parameter, omitting all optional flags. func TestTool_ConfigCI_MinimalArgs(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { t.Fatalf("expected subcommand 'config', got %q", subcommand) } - // "ci" + "--path" + "." = 3 args + // "ci" + "--path" + = 3 args if len(args) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(args), args) } @@ -97,8 +99,8 @@ func TestTool_ConfigCI_MinimalArgs(t *testing.T) { } argsMap := argsToMap(args[1:]) - if val, ok := argsMap["--path"]; !ok || val != "." { - t.Fatalf("expected --path='.', got %q", val) + if val, ok := argsMap["--path"]; !ok || val != path { + t.Fatalf("expected --path=%q, got %q", path, val) } return []byte("GitHub workflow generated successfully\n"), nil @@ -111,7 +113,7 @@ func TestTool_ConfigCI_MinimalArgs(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_ci", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -127,6 +129,7 @@ func TestTool_ConfigCI_MinimalArgs(t *testing.T) { // TestTool_ConfigCI_Error ensures the config_ci tool propagates executor errors, // such as the "unknown command" error returned when FUNC_ENABLE_CI_CONFIG is not set. func TestTool_ConfigCI_Error(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { return []byte(`unknown command "ci" for "config"`), errors.New("executor error") @@ -139,7 +142,7 @@ func TestTool_ConfigCI_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_ci", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_config_envs.go b/pkg/mcp/tools_config_envs.go index 1327f40317..bef77827a5 100644 --- a/pkg/mcp/tools_config_envs.go +++ b/pkg/mcp/tools_config_envs.go @@ -36,6 +36,9 @@ type ConfigEnvsListOutput struct { } func (s *Server) configEnvsListHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigEnvsListInput) (result *mcp.CallToolResult, output ConfigEnvsListOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -146,6 +149,9 @@ func (s *Server) configEnvsAddHandler(ctx context.Context, r *mcp.CallToolReques err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + return + } if err = input.validate(); err != nil { return } @@ -194,6 +200,9 @@ func (s *Server) configEnvsRemoveHandler(ctx context.Context, r *mcp.CallToolReq err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + 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 aa725cddfc..e162fd4b77 100644 --- a/pkg/mcp/tools_config_envs_test.go +++ b/pkg/mcp/tools_config_envs_test.go @@ -12,12 +12,13 @@ import ( // TestTool_ConfigEnvsAdd ensures the config_envs_add tool executes with all arguments. func TestTool_ConfigEnvsAdd(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "name": {"name", "--name", "API_KEY"}, "value": {"value", "--value", "secret123"}, } @@ -76,6 +77,7 @@ func TestTool_ConfigEnvsAdd(t *testing.T) { // TestTool_ConfigEnvsAdd_SecretKey ensures secret-key-sourced env vars produce // the correct "{{ secret:name:key }}" value template. func TestTool_ConfigEnvsAdd_SecretKey(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { @@ -103,7 +105,7 @@ func TestTool_ConfigEnvsAdd_SecretKey(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": path, "name": "API_KEY", "secretName": "my-secret", "secretKey": "MY_KEY", @@ -123,6 +125,7 @@ func TestTool_ConfigEnvsAdd_SecretKey(t *testing.T) { // TestTool_ConfigEnvsAdd_SecretAllKeys ensures importing all keys from a Secret // produces the "{{ secret:name }}" value template without --name. func TestTool_ConfigEnvsAdd_SecretAllKeys(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { @@ -150,7 +153,7 @@ func TestTool_ConfigEnvsAdd_SecretAllKeys(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": path, "secretName": "my-secret", }, }) @@ -168,6 +171,7 @@ func TestTool_ConfigEnvsAdd_SecretAllKeys(t *testing.T) { // TestTool_ConfigEnvsAdd_ConfigMapKey ensures configmap-key-sourced env vars produce // the correct "{{ configMap:name:key }}" value template. func TestTool_ConfigEnvsAdd_ConfigMapKey(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { @@ -195,7 +199,7 @@ func TestTool_ConfigEnvsAdd_ConfigMapKey(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": path, "name": "DATABASE_HOST", "configMapName": "my-config", "configMapKey": "DB_HOST", @@ -215,6 +219,7 @@ func TestTool_ConfigEnvsAdd_ConfigMapKey(t *testing.T) { // TestTool_ConfigEnvsAdd_ConfigMapAllKeys ensures importing all keys from a ConfigMap // produces the "{{ configMap:name }}" value template without --name. func TestTool_ConfigEnvsAdd_ConfigMapAllKeys(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { @@ -242,7 +247,7 @@ func TestTool_ConfigEnvsAdd_ConfigMapAllKeys(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": path, "configMapName": "my-config", }, }) @@ -274,7 +279,7 @@ func TestTool_ConfigEnvsAdd_ValueAndSecretMutuallyExclusive(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "value": "explicit-value", "secretName": "my-secret", @@ -308,7 +313,7 @@ func TestTool_ConfigEnvsAdd_NameWithSecretAllKeys(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "secretName": "my-secret", }, @@ -341,7 +346,7 @@ func TestTool_ConfigEnvsAdd_NameWithConfigMapAllKeys(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "configMapName": "my-config", }, @@ -374,7 +379,7 @@ func TestTool_ConfigEnvsAdd_BothSecretAndConfigMap(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "secretName": "my-secret", "configMapName": "my-config", @@ -408,7 +413,7 @@ func TestTool_ConfigEnvsAdd_SecretKeyWithoutSecretName(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "secretKey": "MY_KEY", }, @@ -441,7 +446,7 @@ func TestTool_ConfigEnvsAdd_ConfigMapKeyWithoutConfigMapName(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", Arguments: map[string]any{ - "path": ".", + "path": t.TempDir(), "name": "MY_VAR", "configMapKey": "MY_KEY", }, @@ -459,13 +464,14 @@ func TestTool_ConfigEnvsAdd_ConfigMapKeyWithoutConfigMapName(t *testing.T) { // TestTool_ConfigEnvsList ensures the config_envs_list tool lists environment variables. func TestTool_ConfigEnvsList(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { t.Fatalf("expected subcommand 'config', got %q", subcommand) } - // "envs" + "--path" + "." = 3 args + // "envs" + "--path" + path = 3 args if len(args) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(args), args) } @@ -474,8 +480,8 @@ func TestTool_ConfigEnvsList(t *testing.T) { } argsMap := argsToMap(args[1:]) - if val, ok := argsMap["--path"]; !ok || val != "." { - t.Fatalf("expected --path='.', got %q", val) + if val, ok := argsMap["--path"]; !ok || val != path { + t.Fatalf("expected --path=%q, got %q", path, val) } return []byte("DATABASE_URL=postgres://localhost\nAPI_KEY=secret\n"), nil @@ -488,7 +494,7 @@ func TestTool_ConfigEnvsList(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -503,12 +509,13 @@ func TestTool_ConfigEnvsList(t *testing.T) { // TestTool_ConfigEnvsRemove ensures the config_envs_remove tool removes an environment variable. func TestTool_ConfigEnvsRemove(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "name": {"name", "--name", "API_KEY"}, } @@ -571,7 +578,7 @@ func TestTool_ConfigEnvsList_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -595,7 +602,7 @@ func TestTool_ConfigEnvsAdd_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_add", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -619,7 +626,7 @@ func TestTool_ConfigEnvsRemove_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_envs_remove", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_config_git.go b/pkg/mcp/tools_config_git.go index 22783e5257..13d6373241 100644 --- a/pkg/mcp/tools_config_git.go +++ b/pkg/mcp/tools_config_git.go @@ -88,6 +88,9 @@ func (s *Server) configGitSetHandler(ctx context.Context, r *mcp.CallToolRequest err = fmt.Errorf("'path' is required") return } + if err = validatePath(input.Path); err != nil { + return + } if input.GitURL == "" { err = fmt.Errorf("'git_url' is required") return @@ -157,6 +160,9 @@ func (s *Server) configGitRemoveHandler(ctx context.Context, r *mcp.CallToolRequ err = fmt.Errorf("'path' is required") return } + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { diff --git a/pkg/mcp/tools_config_git_test.go b/pkg/mcp/tools_config_git_test.go index cfd2a07faf..c95c8dc619 100644 --- a/pkg/mcp/tools_config_git_test.go +++ b/pkg/mcp/tools_config_git_test.go @@ -17,7 +17,7 @@ func TestTool_ConfigGitSet_Args(t *testing.T) { flag string value string }{ - "path": {"path", "--path", "/home/user/myfunc"}, + "path": {"path", "--path", testAbsPath("myfunc")}, "git_url": {"git_url", "--git-url", "https://github.com/user/repo"}, "git_branch": {"git_branch", "--git-branch", "main"}, "git_dir": {"git_dir", "--git-dir", "functions/myfunc"}, @@ -97,7 +97,7 @@ func TestTool_ConfigGitSet_DefaultGitDir(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_set", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "git_url": "https://github.com/user/repo", "git_branch": "main", }, @@ -131,7 +131,7 @@ func TestTool_ConfigGitSet_AntiHang_ConfigLocal(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_set", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "git_url": "https://github.com/user/repo", "git_branch": "main", }, @@ -161,7 +161,7 @@ func TestTool_ConfigGitSet_MissingGitURL(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_set", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "git_branch": "main", }, }) @@ -192,7 +192,7 @@ func TestTool_ConfigGitSet_MissingGitBranch(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_set", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "git_url": "https://github.com/user/repo", }, }) @@ -221,7 +221,7 @@ func TestTool_ConfigGitSet_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_set", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "git_url": "https://github.com/user/repo", "git_branch": "main", }, @@ -242,7 +242,7 @@ func TestTool_ConfigGitRemove_Args(t *testing.T) { flag string value string }{ - "path": {"path", "--path", "/home/user/myfunc"}, + "path": {"path", "--path", testAbsPath("myfunc")}, } boolFlags := map[string]string{ @@ -315,7 +315,7 @@ func TestTool_ConfigGitRemove_AntiHang_DeleteLocal(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_remove", - Arguments: map[string]any{"path": "/home/user/myfunc"}, + Arguments: map[string]any{"path": testAbsPath("myfunc")}, }) if err != nil { t.Fatal(err) @@ -339,7 +339,7 @@ func TestTool_ConfigGitRemove_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_git_remove", - Arguments: map[string]any{"path": "/home/user/myfunc"}, + Arguments: map[string]any{"path": testAbsPath("myfunc")}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_config_labels.go b/pkg/mcp/tools_config_labels.go index 2fa3b23b27..34cd52df8f 100644 --- a/pkg/mcp/tools_config_labels.go +++ b/pkg/mcp/tools_config_labels.go @@ -36,6 +36,9 @@ type ConfigLabelsListOutput struct { } func (s *Server) configLabelsListHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigLabelsListInput) (result *mcp.CallToolResult, output ConfigLabelsListOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -83,6 +86,9 @@ func (s *Server) configLabelsAddHandler(ctx context.Context, r *mcp.CallToolRequ err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -128,6 +134,9 @@ func (s *Server) configLabelsRemoveHandler(ctx context.Context, r *mcp.CallToolR err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + 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 47e0ae9eec..c367c241aa 100644 --- a/pkg/mcp/tools_config_labels_test.go +++ b/pkg/mcp/tools_config_labels_test.go @@ -12,12 +12,13 @@ import ( // TestTool_ConfigLabelsAdd ensures the config_labels_add tool executes with all arguments. func TestTool_ConfigLabelsAdd(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "name": {"name", "--name", "environment"}, "value": {"value", "--value", "prod"}, } @@ -74,13 +75,14 @@ func TestTool_ConfigLabelsAdd(t *testing.T) { // TestTool_ConfigLabelsList ensures the config_labels_list tool lists labels. func TestTool_ConfigLabelsList(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { t.Fatalf("expected subcommand 'config', got %q", subcommand) } - // "labels" + "--path" + "." = 3 args + // "labels" + "--path" + path = 3 args if len(args) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(args), args) } @@ -89,8 +91,8 @@ func TestTool_ConfigLabelsList(t *testing.T) { } argsMap := argsToMap(args[1:]) - if val, ok := argsMap["--path"]; !ok || val != "." { - t.Fatalf("expected --path='.', got %q", val) + if val, ok := argsMap["--path"]; !ok || val != path { + t.Fatalf("expected --path=%q, got %q", path, val) } return []byte("app=my-function\nenvironment=prod\n"), nil @@ -103,7 +105,7 @@ func TestTool_ConfigLabelsList(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_labels_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -118,12 +120,13 @@ func TestTool_ConfigLabelsList(t *testing.T) { // TestTool_ConfigLabelsRemove ensures the config_labels_remove tool removes a label. func TestTool_ConfigLabelsRemove(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "name": {"name", "--name", "environment"}, } @@ -186,7 +189,7 @@ func TestTool_ConfigLabelsList_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_labels_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -210,7 +213,7 @@ func TestTool_ConfigLabelsAdd_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_labels_add", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -234,7 +237,7 @@ func TestTool_ConfigLabelsRemove_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_labels_remove", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_config_volumes.go b/pkg/mcp/tools_config_volumes.go index da36e27a39..724e43ff8c 100644 --- a/pkg/mcp/tools_config_volumes.go +++ b/pkg/mcp/tools_config_volumes.go @@ -36,6 +36,9 @@ type ConfigVolumesListOutput struct { } func (s *Server) configVolumesListHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigVolumesListInput) (result *mcp.CallToolResult, output ConfigVolumesListOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -91,6 +94,9 @@ func (s *Server) configVolumesAddHandler(ctx context.Context, r *mcp.CallToolReq err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "config", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) @@ -136,6 +142,9 @@ func (s *Server) configVolumesRemoveHandler(ctx context.Context, r *mcp.CallTool err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + 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 a2e7bdde33..8c5a8f730f 100644 --- a/pkg/mcp/tools_config_volumes_test.go +++ b/pkg/mcp/tools_config_volumes_test.go @@ -12,12 +12,13 @@ import ( // TestTool_ConfigVolumesAdd ensures the config_volumes_add tool executes with all arguments. func TestTool_ConfigVolumesAdd(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "type": {"type", "--type", "secret"}, "mountPath": {"mountPath", "--mount-path", "/workspace/secret"}, "source": {"source", "--source", "my-secret"}, @@ -78,13 +79,14 @@ func TestTool_ConfigVolumesAdd(t *testing.T) { // TestTool_ConfigVolumesList ensures the config_volumes_list tool lists volumes. func TestTool_ConfigVolumesList(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "config" { t.Fatalf("expected subcommand 'config', got %q", subcommand) } - // "volumes" + "--path" + "." = 3 args + // "volumes" + "--path" + path = 3 args if len(args) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(args), args) } @@ -93,8 +95,8 @@ func TestTool_ConfigVolumesList(t *testing.T) { } argsMap := argsToMap(args[1:]) - if val, ok := argsMap["--path"]; !ok || val != "." { - t.Fatalf("expected --path='.', got %q", val) + if val, ok := argsMap["--path"]; !ok || val != path { + t.Fatalf("expected --path=%q, got %q", path, val) } return []byte("secret:my-secret:/workspace/secret\n"), nil @@ -107,7 +109,7 @@ func TestTool_ConfigVolumesList(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_volumes_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -122,12 +124,13 @@ func TestTool_ConfigVolumesList(t *testing.T) { // TestTool_ConfigVolumesRemove ensures the config_volumes_remove tool removes a volume. func TestTool_ConfigVolumesRemove(t *testing.T) { + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "mountPath": {"mountPath", "--mount-path", "/workspace/secret"}, } @@ -190,7 +193,7 @@ func TestTool_ConfigVolumesList_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_volumes_list", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -214,7 +217,7 @@ func TestTool_ConfigVolumesAdd_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_volumes_add", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) @@ -238,7 +241,7 @@ func TestTool_ConfigVolumesRemove_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "config_volumes_remove", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": t.TempDir()}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_create.go b/pkg/mcp/tools_create.go index 28e4b7a03e..982562143b 100644 --- a/pkg/mcp/tools_create.go +++ b/pkg/mcp/tools_create.go @@ -20,6 +20,9 @@ var createTool = &mcp.Tool{ } func (s *Server) createHandler(ctx context.Context, r *mcp.CallToolRequest, input CreateInput) (result *mcp.CallToolResult, output CreateOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "create", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_create_test.go b/pkg/mcp/tools_create_test.go index d37b284864..d5a2c1c33e 100644 --- a/pkg/mcp/tools_create_test.go +++ b/pkg/mcp/tools_create_test.go @@ -14,12 +14,13 @@ import ( func TestTool_Create_Args(t *testing.T) { // Test data - defined once and used for both input and validation // Note: language (-l) is required and handled separately + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "template": {"template", "--template", "cloudevents"}, "repository": {"repository", "--repository", "https://example.com/repo"}, } @@ -82,9 +83,6 @@ func TestTool_Create_Args(t *testing.T) { } } -// TestCreate_PathValidation is removed - path validation no longer exists -// Create now operates in current working directory - // TestCreate_BinaryFailure ensures errors from the func binary are returned as MCP errors func TestTool_Create_BinaryFailure(t *testing.T) { executor := mock.NewExecutor() @@ -103,7 +101,7 @@ func TestTool_Create_BinaryFailure(t *testing.T) { Name: "create", Arguments: map[string]any{ "language": "go", - "path": ".", + "path": t.TempDir(), }, }) if err != nil { diff --git a/pkg/mcp/tools_delete.go b/pkg/mcp/tools_delete.go index cda2494627..da7c5623ce 100644 --- a/pkg/mcp/tools_delete.go +++ b/pkg/mcp/tools_delete.go @@ -30,6 +30,11 @@ func (s *Server) deleteHandler(ctx context.Context, r *mcp.CallToolRequest, inpu err = fmt.Errorf("exactly one of 'path' or 'name' must be provided") return } + if input.Path != nil { + if err = validatePath(*input.Path); err != nil { + return + } + } // Execute out, err := s.executor.Execute(ctx, "delete", input.Args()...) diff --git a/pkg/mcp/tools_deploy.go b/pkg/mcp/tools_deploy.go index 2734fe0eb6..bea90b2808 100644 --- a/pkg/mcp/tools_deploy.go +++ b/pkg/mcp/tools_deploy.go @@ -24,6 +24,9 @@ func (s *Server) deployHandler(ctx context.Context, r *mcp.CallToolRequest, inpu err = fmt.Errorf("the server is currently in readonly mode. Please set %s and restart the MCP server", EnvMCPWrite) return } + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "deploy", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_deploy_test.go b/pkg/mcp/tools_deploy_test.go index 5184843b19..7e0b636382 100644 --- a/pkg/mcp/tools_deploy_test.go +++ b/pkg/mcp/tools_deploy_test.go @@ -36,12 +36,13 @@ func TestTool_Deploy_Readonly(t *testing.T) { // TestTool_Deploy_Args ensures the deploy tool executes with all arguments passed correctly. func TestTool_Deploy_Args(t *testing.T) { // Test data - defined once and used for both input and validation + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "builder": {"builder", "--builder", "pack"}, "registry": {"registry", "--registry", "ghcr.io/user"}, "image": {"image", "--image", "ghcr.io/user/my-func:latest"}, diff --git a/pkg/mcp/tools_describe.go b/pkg/mcp/tools_describe.go index 270c5b995b..c379c6bc27 100644 --- a/pkg/mcp/tools_describe.go +++ b/pkg/mcp/tools_describe.go @@ -27,6 +27,11 @@ func (s *Server) describeHandler(ctx context.Context, r *mcp.CallToolRequest, in err = fmt.Errorf("exactly one of 'path' or 'name' must be provided") return } + if input.Path != nil { + if err = validatePath(*input.Path); err != nil { + return + } + } // Validate: namespace only makes sense alongside 'name'. When describing // by 'path', the Function's name and namespace are read from its own diff --git a/pkg/mcp/tools_describe_test.go b/pkg/mcp/tools_describe_test.go index 83776d076d..758fa1565d 100644 --- a/pkg/mcp/tools_describe_test.go +++ b/pkg/mcp/tools_describe_test.go @@ -183,7 +183,7 @@ func TestTool_Describe_PathAndNameMutuallyExclusive(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "describe", Arguments: map[string]any{ - "path": "/tmp/my-function", + "path": testAbsPath("my-function"), "name": "my-function", }, }) @@ -214,7 +214,7 @@ func TestTool_Describe_PathAndNamespaceRejected(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "describe", Arguments: map[string]any{ - "path": "/tmp/my-function", + "path": testAbsPath("my-function"), "namespace": "prod", }, }) diff --git a/pkg/mcp/tools_invoke.go b/pkg/mcp/tools_invoke.go index 1a6a57560c..a71fdc29bf 100644 --- a/pkg/mcp/tools_invoke.go +++ b/pkg/mcp/tools_invoke.go @@ -24,6 +24,9 @@ func (s *Server) invokeHandler(ctx context.Context, r *mcp.CallToolRequest, inpu // Note: invoke is intentionally allowed in readonly mode. Readonly mode // only disables operations that mutate cluster state (deploy, delete, // build); invoking a Function does not itself modify the cluster. + if err = validatePath(input.Path); err != nil { + return + } out, err := s.executor.Execute(ctx, "invoke", input.Args()...) if err != nil { err = fmt.Errorf("%w\n%s", err, string(out)) diff --git a/pkg/mcp/tools_invoke_test.go b/pkg/mcp/tools_invoke_test.go index d3a13a3890..7b985f8103 100644 --- a/pkg/mcp/tools_invoke_test.go +++ b/pkg/mcp/tools_invoke_test.go @@ -12,12 +12,13 @@ import ( // TestTool_Invoke_Args ensures the invoke tool executes with all arguments passed correctly. func TestTool_Invoke_Args(t *testing.T) { // Test data - defined once and used for both input and validation + path := t.TempDir() stringFlags := map[string]struct { jsonKey string flag string value string }{ - "path": {"path", "--path", "."}, + "path": {"path", "--path", path}, "target": {"target", "--target", "remote"}, "format": {"format", "--format", "http"}, "id": {"id", "--id", "test-id"}, @@ -74,13 +75,14 @@ func TestTool_Invoke_Args(t *testing.T) { // TestTool_Invoke_Extensions ensures CloudEvent extension attributes are // passed as repeated, deterministically-ordered '--extension key=value' flags. func TestTool_Invoke_Extensions(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "invoke" { t.Fatalf("expected subcommand 'invoke', got %q", subcommand) } want := []string{ - "--path", ".", + "--path", path, "--extension", "priority=high", "--extension", "region=us-east", } @@ -103,7 +105,7 @@ func TestTool_Invoke_Extensions(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "invoke", Arguments: map[string]any{ - "path": ".", + "path": path, "extensions": map[string]any{ "region": "us-east", "priority": "high", @@ -125,12 +127,13 @@ func TestTool_Invoke_Extensions(t *testing.T) { // the required 'path' argument, relying on defaults for everything else // (target auto-discovers between local and remote). func TestTool_Invoke_MinimalArgs(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { if subcommand != "invoke" { t.Fatalf("expected subcommand 'invoke', got %q", subcommand) } - want := []string{"--path", "."} + want := []string{"--path", path} if len(args) != len(want) || args[0] != want[0] || args[1] != want[1] { t.Fatalf("expected args %v, got %v", want, args) } @@ -144,7 +147,7 @@ func TestTool_Invoke_MinimalArgs(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "invoke", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -185,6 +188,7 @@ func TestTool_Invoke_MissingPath(t *testing.T) { // readonly mode, since invoking a Function does not itself mutate cluster // state (unlike deploy, delete, and build, which readonly mode disables). func TestTool_Invoke_Readonly(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { return []byte("OK\n"), nil @@ -197,7 +201,7 @@ func TestTool_Invoke_Readonly(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "invoke", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) @@ -213,6 +217,7 @@ func TestTool_Invoke_Readonly(t *testing.T) { // TestTool_Invoke_Error ensures a failing invocation (e.g. non-2xx response) // is surfaced as a tool error, allowing agents to detect invocation failures. func TestTool_Invoke_Error(t *testing.T) { + path := t.TempDir() executor := mock.NewExecutor() executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { return []byte(""), errors.New("failure invoking function (HTTP 500)") @@ -225,7 +230,7 @@ func TestTool_Invoke_Error(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "invoke", - Arguments: map[string]any{"path": "."}, + Arguments: map[string]any{"path": path}, }) if err != nil { t.Fatal(err) diff --git a/pkg/mcp/tools_logs.go b/pkg/mcp/tools_logs.go index 988e1017ba..7794ea3a29 100644 --- a/pkg/mcp/tools_logs.go +++ b/pkg/mcp/tools_logs.go @@ -65,6 +65,11 @@ func (s *Server) logsHandler(ctx context.Context, r *mcp.CallToolRequest, input err = fmt.Errorf("exactly one of 'path' or 'name' must be provided") return } + if input.Path != nil { + if err = validatePath(*input.Path); err != nil { + return + } + } // ExecuteSplit (rather than Execute/CombinedOutput) is required here so // that the log content is clean stdout only. `func logs` uses stderr for diff --git a/pkg/mcp/tools_logs_test.go b/pkg/mcp/tools_logs_test.go index fedd4c1de5..a7cab410c7 100644 --- a/pkg/mcp/tools_logs_test.go +++ b/pkg/mcp/tools_logs_test.go @@ -21,7 +21,7 @@ func TestTool_Logs_Args_ByPath(t *testing.T) { flag string value string }{ - "path": {"path", "--path", "/home/user/myfunc"}, + "path": {"path", "--path", testAbsPath("myfunc")}, "since": {"since", "--since", "10m"}, } @@ -159,7 +159,7 @@ func TestTool_Logs_MutuallyExclusive(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "logs", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": testAbsPath("myfunc"), "name": "my-function", }, }) @@ -204,11 +204,12 @@ func TestTool_Logs_RequiresPathOrName(t *testing.T) { // and ignores the flag), so rejecting it here would make the tool enforce a // rule 'func logs' does not. func TestTool_Logs_NamespaceWithPath(t *testing.T) { + path := testAbsPath("myfunc") executor := mock.NewExecutor() executor.ExecuteSplitFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, []byte, error) { argsMap := argsToMap(args) - if got := argsMap["--path"]; got != "/home/user/myfunc" { - t.Fatalf("expected --path /home/user/myfunc, got %q", got) + if got := argsMap["--path"]; got != path { + t.Fatalf("expected --path %q, got %q", path, got) } if got := argsMap["--namespace"]; got != "prod" { t.Fatalf("expected --namespace prod, got %q", got) @@ -224,7 +225,7 @@ func TestTool_Logs_NamespaceWithPath(t *testing.T) { result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "logs", Arguments: map[string]any{ - "path": "/home/user/myfunc", + "path": path, "namespace": "prod", }, }) diff --git a/pkg/mcp/tools_run.go b/pkg/mcp/tools_run.go index be56c86121..98392c456e 100644 --- a/pkg/mcp/tools_run.go +++ b/pkg/mcp/tools_run.go @@ -20,6 +20,9 @@ var runTool = &mcp.Tool{ } func (s *Server) runHandler(ctx context.Context, r *mcp.CallToolRequest, input RunInput) (result *mcp.CallToolResult, output RunOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } path, err := resolveRunPath(input.Path) if err != nil { err = fmt.Errorf("unable to resolve function path: %w", err) diff --git a/pkg/mcp/tools_run_stop.go b/pkg/mcp/tools_run_stop.go index 7b3610cd31..152c8a5433 100644 --- a/pkg/mcp/tools_run_stop.go +++ b/pkg/mcp/tools_run_stop.go @@ -20,6 +20,9 @@ var runStopTool = &mcp.Tool{ } func (s *Server) runStopHandler(ctx context.Context, r *mcp.CallToolRequest, input RunStopInput) (result *mcp.CallToolResult, output RunStopOutput, err error) { + if err = validatePath(input.Path); err != nil { + return + } path, err := resolveRunPath(input.Path) if err != nil { err = fmt.Errorf("unable to resolve function path: %w", err) diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 3d30e5c493..0866f8542e 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -92,6 +92,191 @@ func validateBoolFlags(t *testing.T, args []string, boolFlags map[string]string) } } +// TestTool_RejectsRelativePath verifies that every tool handler wired to validatePath +// returns an error result when given a relative path, not just the helper itself. +func TestTool_RejectsRelativePath(t *testing.T) { + cases := []struct { + tool string + args map[string]any + }{ + {"build", map[string]any{"path": "."}}, + {"create", map[string]any{"path": ".", "language": "go"}}, + {"deploy", map[string]any{"path": "."}}, + {"delete", map[string]any{"path": "."}}, + {"config_envs_list", map[string]any{"path": "."}}, + {"config_envs_add", map[string]any{"path": "."}}, + {"config_envs_remove", map[string]any{"path": "."}}, + {"config_labels_list", map[string]any{"path": "."}}, + {"config_labels_add", map[string]any{"path": "."}}, + {"config_labels_remove", map[string]any{"path": "."}}, + {"config_volumes_list", map[string]any{"path": "."}}, + {"config_volumes_add", map[string]any{"path": "."}}, + {"config_volumes_remove", map[string]any{"path": "."}}, + } + for _, tc := range cases { + t.Run(tc.tool, func(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tc.tool, + Arguments: tc.args, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatalf("tool %q: expected error result for relative path, got success", tc.tool) + } + }) + } +} + +// TestValidatePath ensures validatePath accepts absolute paths and rejects relative ones. +func TestValidatePath(t *testing.T) { + if err := validatePath(t.TempDir()); err != nil { + t.Fatalf("expected no error for absolute path, got: %v", err) + } + if err := validatePath("relative/path"); err == nil { + t.Fatal("expected error for relative path, got nil") + } + if err := validatePath("."); err == nil { + t.Fatal("expected error for '.', got nil") + } +} + +// TestRegistry_RejectsRelativePath lists every registered tool whose input +// schema has a "path" property and asserts a relative path is rejected. +// A new tool that takes a path and forgets validatePath fails here. +func TestRegistry_RejectsRelativePath(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + listed, err := client.ListTools(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + + var checked []string + for _, tool := range listed.Tools { + if !schemaHasPath(tool.InputSchema) { + continue + } + checked = append(checked, tool.Name) + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tool.Name, + Arguments: relativePathArgs(tool.InputSchema), + }) + if err != nil { + t.Fatalf("tool %q: call failed: %v", tool.Name, err) + } + if !result.IsError { + t.Errorf("tool %q: relative path was accepted", tool.Name) + continue + } + got := resultToString(result) + if !strings.Contains(got, "absolute") { + t.Errorf("tool %q: expected an absolute-path error, got %q", tool.Name, got) + } + } + if len(checked) == 0 { + t.Fatal("no registered tools expose a path input; schema discovery is broken") + } + for _, name := range []string{ + "config_ci", "config_git_set", "config_git_remove", "invoke", "run", "run_stop", + "describe", "logs", "build", "deploy", "create", + } { + found := false + for _, c := range checked { + if c == name { + found = true + break + } + } + if !found { + t.Errorf("expected tool %q to be covered by the registry check", name) + } + } + t.Logf("checked %d path-taking tools", len(checked)) +} + +// relativePathArgs sets path to "." and fills every other required property +// so schema validation reaches the handler's validatePath check. +func relativePathArgs(schema any) map[string]any { + args := map[string]any{"path": "."} + raw, err := json.Marshal(schema) + if err != nil { + return args + } + var doc struct { + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return args + } + for _, name := range doc.Required { + if name == "path" { + continue + } + args[name] = placeholderForSchemaType(doc.Properties[name]) + } + return args +} + +func placeholderForSchemaType(raw json.RawMessage) any { + var prop struct { + Type any `json:"type"` + } + if err := json.Unmarshal(raw, &prop); err != nil { + return "x" + } + types := schemaTypes(prop.Type) + for _, typ := range types { + switch typ { + case "boolean": + return false + case "number", "integer": + return 0 + } + } + return "x" +} + +func schemaTypes(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func schemaHasPath(schema any) bool { + raw, err := json.Marshal(schema) + if err != nil { + return false + } + var doc struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return false + } + _, ok := doc.Properties["path"] + return ok +} + // buildInputArgs constructs the input arguments map for CallTool from test data. func buildInputArgs(stringFlags map[string]struct { jsonKey string