Files
multica/server/pkg/agent/browser_mcp_config.go
Multica Eve 083e045ec6 MUL-4103: harden Windows browser MCP config (#4976)
* fix: harden Windows browser MCP config

Co-authored-by: multica-agent <github@multica.ai>

* fix: address browser mcp review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 16:14:03 +08:00

206 lines
4.6 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
var (
browserMcpGOOS = runtime.GOOS
browserMcpStat = os.Stat
browserMcpEnv = os.Getenv
)
func hardenBrowserMcpConfig(raw json.RawMessage, tempDir string) ([]byte, error) {
if browserMcpGOOS != "windows" {
return raw, nil
}
return hardenWindowsBrowserMcpConfig(raw, tempDir)
}
func hardenWindowsBrowserMcpConfig(raw json.RawMessage, tempDir string) ([]byte, error) {
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return raw, nil
}
serversRaw, ok := top["mcpServers"]
if !ok {
return raw, nil
}
var servers map[string]json.RawMessage
if err := json.Unmarshal(serversRaw, &servers); err != nil {
return raw, nil
}
changed := false
for name, serverRaw := range servers {
var entry map[string]any
if err := json.Unmarshal(serverRaw, &entry); err != nil {
continue
}
args, ok := stringSlice(entry["args"])
if !ok {
continue
}
lowerName := strings.ToLower(name)
switch {
case lowerName == "playwright" || argsContain(args, "@playwright/mcp") || argsContain(args, `@playwright\mcp`):
nextArgs, err := hardenWindowsPlaywrightMcpArgs(args, tempDir)
if err != nil {
return nil, err
}
if !sameStringSlice(args, nextArgs) {
entry["args"] = nextArgs
servers[name], changed = mustMarshalRaw(entry), true
}
case lowerName == "chrome-devtools" || argsContain(args, "chrome-devtools-mcp"):
if path, ok := windowsChromiumFallbackExecutable(); ok && shouldPinChromeDevToolsExecutable(args) {
entry["args"] = append(args, "--executablePath="+path)
servers[name], changed = mustMarshalRaw(entry), true
}
}
}
if !changed {
return raw, nil
}
top["mcpServers"] = mustMarshalRaw(servers)
data, err := json.Marshal(top)
if err != nil {
return nil, fmt.Errorf("marshal hardened mcp config: %w", err)
}
return data, nil
}
func hardenWindowsPlaywrightMcpArgs(args []string, tempDir string) ([]string, error) {
if hasFlag(args, "--config") || hasFlag(args, "--cdp-endpoint") || hasFlag(args, "--extension") {
return args, nil
}
configPath := filepath.Join(tempDir, "playwright-windows-browser.json")
config := map[string]any{
"browser": map[string]any{
"launchOptions": map[string]any{
"args": []string{"--disable-gpu"},
},
},
}
data, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("marshal playwright mcp browser config: %w", err)
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
return nil, fmt.Errorf("write playwright mcp browser config: %w", err)
}
return append(args, "--config", configPath), nil
}
func windowsChromiumFallbackExecutable() (string, bool) {
if path := strings.TrimSpace(browserMcpEnv("MULTICA_CHROME_DEVTOOLS_EXECUTABLE_PATH")); path != "" {
return path, true
}
for _, root := range []string{
browserMcpEnv("ProgramFiles(x86)"),
browserMcpEnv("ProgramFiles"),
browserMcpEnv("LocalAppData"),
} {
if strings.TrimSpace(root) == "" {
continue
}
path := windowsPathJoin(root, "Microsoft", "Edge", "Application", "msedge.exe")
if _, err := browserMcpStat(path); err == nil {
return path, true
}
}
return "", false
}
func windowsPathJoin(root string, elems ...string) string {
root = strings.TrimRight(root, `\/`)
if root == "" {
return ""
}
return root + `\` + strings.Join(elems, `\`)
}
func shouldPinChromeDevToolsExecutable(args []string) bool {
for _, flag := range []string{
"--executablePath",
"--executable-path",
"-e",
"--channel",
"--browserUrl",
"--browser-url",
"-u",
"--wsEndpoint",
"--ws-endpoint",
"-w",
"--autoConnect",
"--auto-connect",
} {
if hasFlag(args, flag) {
return false
}
}
return true
}
func stringSlice(v any) ([]string, bool) {
raw, ok := v.([]any)
if !ok {
return nil, false
}
out := make([]string, 0, len(raw))
for _, item := range raw {
s, ok := item.(string)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func argsContain(args []string, needle string) bool {
needle = strings.ToLower(needle)
for _, arg := range args {
if strings.Contains(strings.ToLower(arg), needle) {
return true
}
}
return false
}
func hasFlag(args []string, flag string) bool {
for _, arg := range args {
if arg == flag || strings.HasPrefix(arg, flag+"=") {
return true
}
}
return false
}
func sameStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func mustMarshalRaw(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return data
}