|
1 | 1 | package copilotconfig |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
4 | 5 | "fmt" |
| 6 | + "os" |
| 7 | + "path/filepath" |
5 | 8 | "strings" |
6 | 9 |
|
7 | 10 | copilot "github.com/github/copilot-sdk/go" |
8 | 11 | "github.com/go-viper/mapstructure/v2" |
| 12 | + "github.com/microsoft/waza/internal/mcpmock" |
| 13 | + "github.com/microsoft/waza/internal/models" |
9 | 14 | ) |
10 | 15 |
|
11 | 16 | // ConvertMCPServers converts eval YAML mcp_servers entries into Copilot SDK MCP |
12 | 17 | // configs. Invalid entries are skipped after emitting a warning through warnf. |
13 | 18 | func ConvertMCPServers(serverConfigs map[string]any, warnf func(string, ...any)) map[string]copilot.MCPServerConfig { |
| 19 | + return ConvertMCPServersWithMocks(serverConfigs, nil, "", warnf) |
| 20 | +} |
| 21 | + |
| 22 | +// ConvertMCPServersWithMocks converts eval YAML mcp_servers entries and |
| 23 | +// mcp_mocks entries into Copilot SDK MCP configs. Mock servers are exposed as |
| 24 | +// hermetic stdio MCP servers backed by this waza binary. |
| 25 | +func ConvertMCPServersWithMocks(serverConfigs map[string]any, mocks []models.MCPMockConfig, baseDir string, warnf func(string, ...any)) map[string]copilot.MCPServerConfig { |
| 26 | + if warnf == nil { |
| 27 | + warnf = func(string, ...any) {} |
| 28 | + } |
14 | 29 | if len(serverConfigs) == 0 { |
15 | | - return nil |
| 30 | + if len(mocks) == 0 { |
| 31 | + return nil |
| 32 | + } |
16 | 33 | } |
17 | 34 |
|
18 | | - result := make(map[string]copilot.MCPServerConfig, len(serverConfigs)) |
| 35 | + result := make(map[string]copilot.MCPServerConfig, len(serverConfigs)+len(mocks)) |
19 | 36 | for name, cfg := range serverConfigs { |
20 | 37 | cfgMap, ok := cfg.(map[string]any) |
21 | 38 | if !ok { |
@@ -44,9 +61,92 @@ func ConvertMCPServers(serverConfigs map[string]any, warnf func(string, ...any)) |
44 | 61 | } |
45 | 62 | } |
46 | 63 |
|
| 64 | + for _, mock := range mocks { |
| 65 | + mockName := strings.TrimSpace(mock.Name) |
| 66 | + if mockName != "" { |
| 67 | + delete(result, mockName) |
| 68 | + } |
| 69 | + cfg, err := mcpmock.FromEvalConfig(mock, baseDir) |
| 70 | + if err != nil { |
| 71 | + warnf("Warning: mcp_mock %q config is invalid: %v, skipping\n", mock.Name, err) |
| 72 | + continue |
| 73 | + } |
| 74 | + stdio, err := mockServerConfig(*cfg) |
| 75 | + if err != nil { |
| 76 | + warnf("Warning: mcp_mock %q could not be configured: %v, skipping\n", cfg.Name, err) |
| 77 | + continue |
| 78 | + } |
| 79 | + result[cfg.Name] = stdio |
| 80 | + } |
| 81 | + |
| 82 | + if len(result) == 0 { |
| 83 | + return nil |
| 84 | + } |
47 | 85 | return result |
48 | 86 | } |
49 | 87 |
|
| 88 | +func mockServerConfig(cfg mcpmock.Config) (copilot.MCPStdioServerConfig, error) { |
| 89 | + data, err := json.Marshal(cfg) |
| 90 | + if err != nil { |
| 91 | + return copilot.MCPStdioServerConfig{}, fmt.Errorf("marshal mock config: %w", err) |
| 92 | + } |
| 93 | + configFile, err := writeMockConfigFile(cfg.Name, data) |
| 94 | + if err != nil { |
| 95 | + return copilot.MCPStdioServerConfig{}, err |
| 96 | + } |
| 97 | + exe, err := os.Executable() |
| 98 | + if err != nil { |
| 99 | + return copilot.MCPStdioServerConfig{}, fmt.Errorf("resolve waza executable: %w", err) |
| 100 | + } |
| 101 | + return copilot.MCPStdioServerConfig{ |
| 102 | + Command: exe, |
| 103 | + Args: []string{"__mcp-mock", "--config-file", configFile}, |
| 104 | + Env: map[string]string{ |
| 105 | + "WAZA_NO_UPDATE_CHECK": "1", |
| 106 | + }, |
| 107 | + }, nil |
| 108 | +} |
| 109 | + |
| 110 | +func writeMockConfigFile(name string, data []byte) (string, error) { |
| 111 | + safeName := strings.Map(func(r rune) rune { |
| 112 | + switch { |
| 113 | + case r >= 'a' && r <= 'z': |
| 114 | + return r |
| 115 | + case r >= 'A' && r <= 'Z': |
| 116 | + return r |
| 117 | + case r >= '0' && r <= '9': |
| 118 | + return r |
| 119 | + case r == '-' || r == '_': |
| 120 | + return r |
| 121 | + default: |
| 122 | + return '-' |
| 123 | + } |
| 124 | + }, name) |
| 125 | + if safeName == "" { |
| 126 | + safeName = "mock" |
| 127 | + } |
| 128 | + path := filepath.Join(os.TempDir(), fmt.Sprintf("waza-mcp-mock-%s-*.json", safeName)) |
| 129 | + file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)) |
| 130 | + if err != nil { |
| 131 | + return "", fmt.Errorf("create mock config file: %w", err) |
| 132 | + } |
| 133 | + if err := file.Chmod(0600); err != nil { |
| 134 | + _ = file.Close() |
| 135 | + _ = os.Remove(file.Name()) |
| 136 | + return "", fmt.Errorf("secure mock config file: %w", err) |
| 137 | + } |
| 138 | + if _, err := file.Write(data); err != nil { |
| 139 | + _ = file.Close() |
| 140 | + _ = os.Remove(file.Name()) |
| 141 | + return "", fmt.Errorf("write mock config file: %w", err) |
| 142 | + } |
| 143 | + if err := file.Close(); err != nil { |
| 144 | + _ = os.Remove(file.Name()) |
| 145 | + return "", fmt.Errorf("close mock config file: %w", err) |
| 146 | + } |
| 147 | + return file.Name(), nil |
| 148 | +} |
| 149 | + |
50 | 150 | func decode(input map[string]any, output any) error { |
51 | 151 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ |
52 | 152 | Result: output, |
|
0 commit comments