diff --git a/cmd/mcp/config.go b/cmd/mcp/config.go index 589e3d3f..cfb19be0 100644 --- a/cmd/mcp/config.go +++ b/cmd/mcp/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sort" "strconv" "strings" @@ -29,17 +30,18 @@ type configField struct { } type targetSpec struct { - target Target - description string - path func(string) string - section string - transport transport - fields []configField - stdioArgs []string - remove []string - legacyName string - legacyKey string - printOnly bool + target Target + description string + path func(string) string + section string + transport transport + fields []configField + clientName string + callbackPort int + remove []string + legacyName string + legacyKey string + printOnly bool } func homePath(parts ...string) func(string) string { @@ -80,18 +82,17 @@ func appDataPath(home string) string { var targetSpecs = []targetSpec{ {target: TargetCursor, description: "Cursor editor", path: homePath(".cursor", "mcp.json"), section: "mcpServers", transport: http, fields: []configField{{name: "url", value: KernelMCPURL}}, remove: []string{"type"}}, - {target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio}, + {target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio, clientName: "Claude Desktop", callbackPort: 46093}, {target: TargetClaudeCode, description: "Claude Code CLI", path: homePath(".claude.json"), section: "mcpServers", transport: http, fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}}}, - {target: TargetAntigravity, description: "Google Antigravity", path: homePath(".gemini", "config", "mcp_config.json"), section: "mcpServers", transport: stdio, - stdioArgs: []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", "{\"client_name\":\"Antigravity\"}"}}, - {target: TargetWindsurf, description: "Windsurf editor", path: homePath(".codeium", "windsurf", "mcp_config.json"), section: "mcpServers", transport: stdio}, + {target: TargetAntigravity, description: "Google Antigravity", path: homePath(".gemini", "config", "mcp_config.json"), section: "mcpServers", transport: stdio, clientName: "Antigravity", callbackPort: 46094}, + {target: TargetWindsurf, description: "Windsurf editor", path: homePath(".codeium", "windsurf", "mcp_config.json"), section: "mcpServers", transport: stdio, clientName: "Windsurf", callbackPort: 46095}, {target: TargetVSCode, description: "Visual Studio Code", path: vsCodePath, section: "servers", transport: http, legacyName: "settings.json", legacyKey: "mcp.servers", fields: []configField{{name: "url", value: KernelMCPURL}, {name: "type", value: "http"}}}, - {target: TargetGoose, description: "Goose AI", path: homePath(".config", "goose", "config.yaml"), transport: stdio, printOnly: true}, + {target: TargetGoose, description: "Goose AI", path: homePath(".config", "goose", "config.yaml"), transport: stdio, clientName: "Goose", callbackPort: 46096, printOnly: true}, // Current Zed settings omit source; its settings migrator removes that old field. - {target: TargetZed, description: "Zed editor", path: homePath(".config", "zed", "settings.json"), section: "context_servers", transport: stdio, + {target: TargetZed, description: "Zed editor", path: homePath(".config", "zed", "settings.json"), section: "context_servers", transport: stdio, clientName: "Zed", callbackPort: 46097, remove: []string{"source"}}, {target: TargetFx, description: "fx coding agent", path: homePath(".fx", "mcp.json"), section: "mcp", transport: http, fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}, {name: "oauth", value: map[string]any{}, ifMissing: true, skipWhen: "bearer_token_env"}}}, @@ -126,6 +127,14 @@ func getConfigPath(target Target) (string, error) { return spec.path(home), nil } +func clientCacheDir(target Target) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".mcp-auth", "kernel-"+string(target)), nil +} + func GetConfigPath(target Target) (string, error) { return getConfigPath(target) } @@ -140,7 +149,7 @@ func Install(target Target) error { return err } if spec.printOnly { - return installForGoose(path) + return installForGoose(path, spec) } return installConfig(path, spec) } @@ -319,11 +328,7 @@ func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage } fields := spec.fields if spec.transport == stdio { - args := spec.stdioArgs - if args == nil { - args = []string{"-y", "mcp-remote", KernelMCPURL} - } - args, err = mergeStdioArgs(kernel, args) + args, err := mergeStdioArgs(kernel, spec) if err != nil { return nil, err } @@ -367,10 +372,57 @@ func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage return nil, err } } + if spec.transport == stdio { + if err := addClientCacheDir(&root, kernelPath, spec.target); err != nil { + return nil, err + } + } return root.Pack(), nil } -func mergeStdioArgs(kernel *hujson.Object, defaults []string) ([]string, error) { +func addClientCacheDir(root *hujson.Value, kernelPath string, target Target) error { + kernel, err := objectAt(root, kernelPath) + if err != nil { + return err + } + if _, exists := member(kernel, "env"); !exists { + if err := patch(root, "add", kernelPath+"/env", map[string]any{}); err != nil { + return err + } + } + env, err := objectAt(root, kernelPath+"/env") + if err != nil { + return err + } + current, exists := member(env, "MCP_REMOTE_CONFIG_DIR") + if exists { + normalized := current.Clone() + normalized.Standardize() + var path string + if err := json.Unmarshal(normalized.Pack(), &path); err != nil { + return fmt.Errorf("invalid MCP_REMOTE_CONFIG_DIR: %w", err) + } + if path != "" { + return nil + } + } + cacheDir, err := clientCacheDir(target) + if err != nil { + return err + } + return patch(root, "add", kernelPath+"/env/MCP_REMOTE_CONFIG_DIR", cacheDir) +} + +func stdioArgs(spec targetSpec) []string { + return []string{"-y", "mcp-remote", KernelMCPURL, strconv.Itoa(spec.callbackPort), "--static-oauth-client-metadata", clientMetadata(spec.clientName)} +} + +func clientMetadata(clientName string) string { + return fmt.Sprintf(`{"client_name":%q}`, clientName) +} + +func mergeStdioArgs(kernel *hujson.Object, spec targetSpec) ([]string, error) { + defaults := stdioArgs(spec) value, exists := member(kernel, "args") if !exists { return defaults, nil @@ -385,22 +437,62 @@ func mergeStdioArgs(kernel *hujson.Object, defaults []string) ([]string, error) return defaults, nil } args[2] = KernelMCPURL - if len(defaults) > 3 { - flag := defaults[3] - for i := 3; i < len(args); i++ { - if args[i] == flag { - if i+1 >= len(args) { - return nil, fmt.Errorf("kernel args missing value for %s", flag) - } - args[i+1] = defaults[4] - return args, nil - } + if len(args) == 3 { + args = append(args, defaults[3]) + } else if _, err := strconv.Atoi(args[3]); err != nil { + args = slices.Insert(args, 3, defaults[3]) + } + flagIndex := -1 + for i := 4; i < len(args); i++ { + if args[i] != defaults[4] { + continue + } + if flagIndex != -1 || i+1 >= len(args) { + return nil, fmt.Errorf("invalid kernel args for %s", defaults[4]) } - args = append(args, defaults[3:]...) + flagIndex = i + } + if flagIndex == -1 { + return append(args, defaults[4:]...), nil + } + metadata, err := mergeClientMetadata(args[flagIndex+1], spec.clientName) + if err != nil { + return nil, err } + args[flagIndex+1] = metadata return args, nil } +func mergeClientMetadata(raw, clientName string) (string, error) { + if strings.HasPrefix(raw, "@") { + return "", fmt.Errorf("kernel OAuth metadata is in a separate file; set client_name to %q there", clientName) + } + if !json.Valid([]byte(raw)) { + return "", fmt.Errorf("invalid kernel OAuth metadata: expected JSON object") + } + metadata, err := hujson.Parse([]byte(raw)) + if err != nil { + return "", fmt.Errorf("invalid kernel OAuth metadata: %w", err) + } + if err := validateConfigKeys(&metadata, ""); err != nil { + return "", err + } + obj, err := objectAt(&metadata, "") + if err != nil { + return "", err + } + if current, exists := member(obj, "client_name"); exists { + var name string + if err := json.Unmarshal(current.Pack(), &name); err == nil && name == clientName { + return raw, nil + } + } + if err := patch(&metadata, "add", "/client_name", clientName); err != nil { + return "", err + } + return string(metadata.Pack()), nil +} + func pointerName(name string) string { return strings.ReplaceAll(strings.ReplaceAll(name, "~", "~0"), "/", "~1") } diff --git a/cmd/mcp/config_test.go b/cmd/mcp/config_test.go index 694933d0..1fe7f24c 100644 --- a/cmd/mcp/config_test.go +++ b/cmd/mcp/config_test.go @@ -43,15 +43,17 @@ func TestInstallPreservesTargetConfigs(t *testing.T) { target Target section string stdio bool + name string + port string }{ - {TargetCursor, "mcpServers", false}, - {TargetClaude, "mcpServers", true}, - {TargetClaudeCode, "mcpServers", false}, - {TargetAntigravity, "mcpServers", true}, - {TargetWindsurf, "mcpServers", true}, - {TargetVSCode, "servers", false}, - {TargetZed, "context_servers", true}, - {TargetFx, "mcp", false}, + {TargetCursor, "mcpServers", false, "", ""}, + {TargetClaude, "mcpServers", true, "Claude Desktop", "46093"}, + {TargetClaudeCode, "mcpServers", false, "", ""}, + {TargetAntigravity, "mcpServers", true, "Antigravity", "46094"}, + {TargetWindsurf, "mcpServers", true, "Windsurf", "46095"}, + {TargetVSCode, "servers", false, "", ""}, + {TargetZed, "context_servers", true, "Zed", "46097"}, + {TargetFx, "mcp", false, "", ""}, } if got := AllTargets(); len(got) != len(targets)+1 { t.Fatalf("registered targets = %d, want %d including Goose", len(got), len(targets)+1) @@ -126,8 +128,17 @@ func TestInstallPreservesTargetConfigs(t *testing.T) { if args[0] != "-y" || args[1] != "mcp-remote" || args[2] != KernelMCPURL { t.Fatalf("stdio args = %#v", args) } - if tc.target == TargetAntigravity && (len(args) != 5 || args[3] != "--static-oauth-client-metadata") { - t.Fatalf("Antigravity args = %#v", args) + if len(args) != 6 || args[3] != tc.port || args[4] != "--static-oauth-client-metadata" { + t.Fatalf("stdio metadata args = %#v", args) + } + var metadata map[string]string + if err := json.Unmarshal([]byte(args[5].(string)), &metadata); err != nil || metadata["client_name"] != tc.name { + t.Fatalf("stdio client metadata = %#v: %v", metadata, err) + } + cacheDir := kernel["env"].(map[string]any)["MCP_REMOTE_CONFIG_DIR"] + wantDir := filepath.Join(os.Getenv("HOME"), ".mcp-auth", "kernel-"+string(tc.target)) + if cacheDir != wantDir { + t.Fatalf("auth cache = %v, want %s", cacheDir, wantDir) } if tc.target == TargetZed { if _, exists := kernel["source"]; exists { @@ -619,7 +630,9 @@ func TestInstallVSCodeWriteFailureDoesNotChmodLegacy(t *testing.T) { } func TestInstallPreservesMCPRemoteOptions(t *testing.T) { - for _, target := range []Target{TargetClaude, TargetAntigravity, TargetWindsurf, TargetZed} { + for target, port := range map[Target]string{ + TargetClaude: "46093", TargetAntigravity: "46094", TargetWindsurf: "46095", TargetZed: "46097", + } { t.Run(string(target), func(t *testing.T) { testHome(t) path, err := GetConfigPath(target) @@ -632,7 +645,8 @@ func TestInstallPreservesMCPRemoteOptions(t *testing.T) { spec, _ := specFor(target) seed := map[string]any{spec.section: map[string]any{"kernel": map[string]any{ "command": "npx", - "args": []string{"-y", "mcp-remote@latest", "https://old.example", "--header-file", "/private/headers.txt"}, + "args": []string{"-y", "mcp-remote@latest", "https://old.example", "--header-file", "/private/headers.txt", "--static-oauth-client-metadata", `{"client_name":"Old","scope":"read","token_endpoint_auth_method":"none"}`}, + "env": map[string]string{"CUSTOM": "keep"}, }}} data, err := json.Marshal(seed) if err != nil { @@ -646,16 +660,189 @@ func TestInstallPreservesMCPRemoteOptions(t *testing.T) { } config := readTestConfig(t, path) args := config[spec.section].(map[string]any)["kernel"].(map[string]any)["args"].([]any) - if args[1] != "mcp-remote@latest" || args[2] != KernelMCPURL || args[3] != "--header-file" || args[4] != "/private/headers.txt" { + if len(args) != 8 || args[1] != "mcp-remote@latest" || args[2] != KernelMCPURL || args[3] != port || args[4] != "--header-file" || args[5] != "/private/headers.txt" { t.Fatalf("custom args lost: %#v", args) } - if target == TargetAntigravity && (len(args) != 7 || args[5] != "--static-oauth-client-metadata") { - t.Fatalf("Antigravity metadata missing: %#v", args) + if args[6] != "--static-oauth-client-metadata" { + t.Fatalf("client metadata missing: %#v", args) + } + var metadata map[string]any + if err := json.Unmarshal([]byte(args[7].(string)), &metadata); err != nil { + t.Fatal(err) + } + if metadata["client_name"] != spec.clientName || metadata["scope"] != "read" || metadata["token_endpoint_auth_method"] != "none" { + t.Fatalf("client metadata changed: %#v", metadata) + } + env := config[spec.section].(map[string]any)["kernel"].(map[string]any)["env"].(map[string]any) + if env["CUSTOM"] != "keep" || env["MCP_REMOTE_CONFIG_DIR"] == nil { + t.Fatalf("stdio environment changed: %#v", env) + } + }) + } +} + +func TestInstallPreservesExplicitCallbackPort(t *testing.T) { + for _, target := range []Target{TargetClaude, TargetAntigravity, TargetWindsurf, TargetZed} { + t.Run(string(target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(target) + if err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + config := readTestConfig(t, path) + spec, _ := specFor(target) + kernel := config[spec.section].(map[string]any)["kernel"].(map[string]any) + args := []any{"-y", "mcp-remote@latest", KernelMCPURL, "54321", "--host", "127.0.0.1", "--static-oauth-client-metadata", clientMetadata(spec.clientName)} + kernel["args"] = args + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + got := readTestConfig(t, path)[spec.section].(map[string]any)["kernel"].(map[string]any)["args"] + if !reflect.DeepEqual(got, args) { + t.Fatalf("custom callback args = %#v, want %#v", got, args) } }) } } +func TestInstallPreservesCustomClientCache(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetClaude) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"mcpServers":{"kernel":{"env":{"MCP_REMOTE_CONFIG_DIR":"/custom/cache"}}}}`) + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetClaude); err != nil { + t.Fatal(err) + } + env := readTestConfig(t, path)["mcpServers"].(map[string]any)["kernel"].(map[string]any)["env"].(map[string]any) + if env["MCP_REMOTE_CONFIG_DIR"] != "/custom/cache" { + t.Fatalf("custom auth cache changed: %#v", env) + } +} + +func TestInstallReplacesEmptyClientCache(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetClaude) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"mcpServers":{"kernel":{"env":{"MCP_REMOTE_CONFIG_DIR":""}}}}`) + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetClaude); err != nil { + t.Fatal(err) + } + env := readTestConfig(t, path)["mcpServers"].(map[string]any)["kernel"].(map[string]any)["env"].(map[string]any) + want, err := clientCacheDir(TargetClaude) + if err != nil { + t.Fatal(err) + } + if env["MCP_REMOTE_CONFIG_DIR"] != want { + t.Fatalf("auth cache = %v, want %s", env["MCP_REMOTE_CONFIG_DIR"], want) + } +} + +func TestInstallRejectsExternalClientMetadata(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetClaude) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"mcpServers":{"kernel":{"args":["-y","mcp-remote","https://old.example","--static-oauth-client-metadata","@/custom/metadata.json"]}}}`) + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetClaude); err == nil { + t.Fatal("expected external metadata to require manual update") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, seed) { + t.Fatal("external metadata config changed") + } +} + +func TestInstallRejectsDuplicateClientMetadata(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetClaude) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed, err := json.Marshal(map[string]any{"mcpServers": map[string]any{"kernel": map[string]any{ + "args": []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", `{"client_name":"Claude Desktop","client_name":"Other"}`}, + }}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetClaude); err == nil || !strings.Contains(err.Error(), `duplicate config key "client_name"`) { + t.Fatalf("install error = %v, want duplicate client name", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, seed) { + t.Fatal("duplicate client metadata changed config") + } +} + +func TestInstallRejectsNonObjectEnvironment(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetClaude) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"mcpServers":{"kernel":{"env":"invalid"}}}`) + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetClaude); err == nil { + t.Fatal("expected invalid environment to fail") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, seed) { + t.Fatal("invalid config changed") + } +} + func TestInstallFxKeepsBearerTokenAuth(t *testing.T) { testHome(t) path, err := GetConfigPath(TargetFx) diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 1dc4fab2..3111e271 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -2,6 +2,7 @@ package mcp import ( "fmt" + "strings" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -45,21 +46,34 @@ type MCPServerConfig struct { } // installForGoose installs MCP config for Goose (YAML format) -func installForGoose(configPath string) error { +func installForGoose(configPath string, spec targetSpec) error { + cacheDir, err := clientCacheDir(spec.target) + if err != nil { + return err + } // For Goose, we'll output instructions since it uses YAML format // and we don't want to add a YAML dependency pterm.Info.Println("Goose uses YAML configuration. Add the following to your Goose config:") pterm.Println() - fmt.Println(`extensions: + fmt.Println(gooseConfig(spec, cacheDir)) + pterm.Println() + pterm.Info.Printf("Config file location: %s\n", configPath) + return nil +} + +func gooseConfig(spec targetSpec, cacheDir string) string { + var config strings.Builder + config.WriteString(`extensions: kernel: name: Kernel type: stdio + enabled: true cmd: npx args: - - -y - - mcp-remote - - ` + KernelMCPURL) - pterm.Println() - pterm.Info.Printf("Config file location: %s\n", configPath) - return nil +`) + for _, arg := range stdioArgs(spec) { + fmt.Fprintf(&config, " - %q\n", arg) + } + fmt.Fprintf(&config, " envs:\n MCP_REMOTE_CONFIG_DIR: %q", cacheDir) + return config.String() } diff --git a/cmd/mcp/mcp_test.go b/cmd/mcp/mcp_test.go index 72fcd86b..586079e7 100644 --- a/cmd/mcp/mcp_test.go +++ b/cmd/mcp/mcp_test.go @@ -4,10 +4,41 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "runtime" "testing" + + "gopkg.in/yaml.v3" ) +func TestGooseConfigMatchesStdioArgs(t *testing.T) { + spec, _ := specFor(TargetGoose) + cacheDir := "/home/example/.mcp-auth/kernel-goose" + var config struct { + Extensions map[string]struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Enabled bool `yaml:"enabled"` + Cmd string `yaml:"cmd"` + Args []string `yaml:"args"` + Envs map[string]string `yaml:"envs"` + } `yaml:"extensions"` + } + if err := yaml.Unmarshal([]byte(gooseConfig(spec, cacheDir)), &config); err != nil { + t.Fatal(err) + } + goose := config.Extensions["kernel"] + if !reflect.DeepEqual(goose.Args, stdioArgs(spec)) { + t.Fatalf("Goose args = %q, want %q", goose.Args, stdioArgs(spec)) + } + if goose.Name != "Kernel" || goose.Type != "stdio" || !goose.Enabled || goose.Cmd != "npx" { + t.Fatalf("Goose extension = %+v", goose) + } + if goose.Envs["MCP_REMOTE_CONFIG_DIR"] != cacheDir { + t.Fatalf("Goose cache = %q, want %q", goose.Envs["MCP_REMOTE_CONFIG_DIR"], cacheDir) + } +} + func TestInstallForFx(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -130,7 +161,7 @@ func assertMCPRemoteShape(t *testing.T, kernel map[string]interface{}) { if !ok { t.Fatalf("args = %#v, want array", kernel["args"]) } - want := []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", `{"client_name":"Antigravity"}`} + want := []string{"-y", "mcp-remote", KernelMCPURL, "46094", "--static-oauth-client-metadata", `{"client_name":"Antigravity"}`} if len(args) != len(want) { t.Fatalf("args = %v, want %v", args, want) } diff --git a/go.mod b/go.mod index f5367d34..8655cc53 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -59,5 +60,4 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect )