Skip to content

Commit ec8cb62

Browse files
spboyerCopilot
andauthored
feat: add MCP server mocks (closes #363) (#387)
* feat: add MCP server mocks #363 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden MCP mock config handling #363 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ac7c35e commit ec8cb62

19 files changed

Lines changed: 1275 additions & 11 deletions

File tree

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,7 @@ skills/ Example skills
943943
```yaml
944944
name: my-eval
945945
skill: my-skill
946-
schemaVersion: "1.0"
946+
schemaVersion: "1.1"
947947
version: "1.0"
948948
949949
config:
@@ -988,6 +988,26 @@ hooks:
988988
exit_codes: [0]
989989
error_on_fail: false
990990
991+
mcp_mocks:
992+
- name: github
993+
tools:
994+
list_issues:
995+
input_schema:
996+
type: object
997+
required: [owner, repo]
998+
responses:
999+
- match:
1000+
owner: microsoft
1001+
repo: waza
1002+
return:
1003+
issues:
1004+
- number: 363
1005+
title: MCP server mocks for hermetic eval
1006+
- match_regex:
1007+
repo: "^waza-.*"
1008+
return:
1009+
issues: []
1010+
9911011
graders:
9921012
- type: text
9931013
name: pattern_check
@@ -1017,6 +1037,19 @@ tasks:
10171037

10181038
`schemaVersion` uses `MAJOR.MINOR` format and defaults to `1.0` when omitted for backward compatibility. Readers allow same-major minor additions with warnings for unknown fields, but reject different majors with a hint to run `waza migrate <file>`.
10191039

1040+
### MCP Mock Servers
1041+
1042+
Use top-level `mcp_mocks` with `schemaVersion: "1.1"` for deterministic Copilot SDK evals that need MCP tools without live services. Waza launches each mock as a local stdio MCP server, so CI runs do not need network ports, external credentials, or real service state.
1043+
1044+
```yaml
1045+
schemaVersion: "1.1"
1046+
mcp_mocks:
1047+
- name: github
1048+
fixtures: fixtures/mcp/github
1049+
```
1050+
1051+
Inline responses support exact full-argument matching (`match`), JSON Schema matching (`match_schema`), and per-field regex matching (`match_regex`). Unknown tools and unmatched calls fail loudly with an MCP tool error that names the missing fixture.
1052+
10201053
### Custom Input Variables
10211054

10221055
Use the `inputs` section to define key-value variables available throughout your evaluation as `{{.Vars.key}}`:

cmd/waza/cmd_mcp_mock.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"log/slog"
9+
"os"
10+
11+
"github.com/microsoft/waza/internal/mcpmock"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func newMCPMockCommand() *cobra.Command {
16+
var configBase64 string
17+
var configFile string
18+
cmd := &cobra.Command{
19+
Use: "__mcp-mock",
20+
Short: "Run an internal MCP mock server",
21+
Hidden: true,
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
data, err := readMCPMockConfig(configBase64, configFile)
24+
if err != nil {
25+
return err
26+
}
27+
var cfg mcpmock.Config
28+
if err := json.Unmarshal(data, &cfg); err != nil {
29+
return fmt.Errorf("parse mock config: %w", err)
30+
}
31+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
32+
mcpmock.ServeStdio(context.Background(), &cfg, os.Stdin, os.Stdout, logger)
33+
return nil
34+
},
35+
}
36+
cmd.Flags().StringVar(&configBase64, "config-base64", "", "base64-encoded MCP mock config")
37+
cmd.Flags().StringVar(&configFile, "config-file", "", "path to MCP mock config JSON")
38+
return cmd
39+
}
40+
41+
func readMCPMockConfig(configBase64, configFile string) ([]byte, error) {
42+
switch {
43+
case configFile != "":
44+
data, err := os.ReadFile(configFile)
45+
if err != nil {
46+
return nil, fmt.Errorf("read mock config file: %w", err)
47+
}
48+
if err := os.Remove(configFile); err != nil {
49+
return nil, fmt.Errorf("remove mock config file: %w", err)
50+
}
51+
return data, nil
52+
case configBase64 != "":
53+
data, err := base64.StdEncoding.DecodeString(configBase64)
54+
if err != nil {
55+
return nil, fmt.Errorf("decode mock config: %w", err)
56+
}
57+
return data, nil
58+
default:
59+
return nil, fmt.Errorf("--config-file or --config-base64 is required")
60+
}
61+
}

cmd/waza/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ performance against predefined test cases.`,
7171
cmd.AddCommand(newUpdateCommand())
7272
cmd.AddCommand(newSpecCommand())
7373
cmd.AddCommand(newMigrateCommand())
74+
cmd.AddCommand(newMCPMockCommand())
7475

7576
return cmd
7677
}

docs/INTEGRATION-TESTING.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,51 @@ config:
4747
args: ["-y", "@azure/mcp", "server", "start"]
4848
```
4949
50+
### Hermetic MCP Mock Servers
51+
52+
For CI-safe Copilot SDK evals, prefer top-level `mcp_mocks` over live `config.mcp_servers`. Mock servers run as local stdio MCP servers managed by waza, so they do not bind ports, make network calls, or require service credentials. Because `mcp_mocks` is an additive eval schema field, set `schemaVersion: "1.1"`.
53+
54+
```yaml
55+
name: github-triage
56+
skill: issue-triage
57+
schemaVersion: "1.1"
58+
version: "1.0"
59+
60+
config:
61+
trials_per_task: 1
62+
executor: copilot-sdk
63+
model: claude-sonnet-4.6
64+
timeout_seconds: 300
65+
66+
mcp_mocks:
67+
- name: github
68+
tools:
69+
list_issues:
70+
input_schema:
71+
type: object
72+
properties:
73+
owner: { type: string }
74+
repo: { type: string }
75+
required: [owner, repo]
76+
responses:
77+
- match:
78+
owner: microsoft
79+
repo: waza
80+
return:
81+
issues:
82+
- number: 363
83+
title: MCP server mocks for hermetic eval
84+
- match_schema:
85+
type: object
86+
required: [owner, repo]
87+
error: "No matching issue fixture"
88+
89+
tasks:
90+
- tasks/*.yaml
91+
```
92+
93+
Responses are matched in order. Use `match` for exact full-argument fixtures, `match_schema` for JSON Schema matching, and `match_regex` for per-field regular expressions. Unknown tools or unmatched calls fail with a clear MCP tool error so missing fixtures do not pass silently.
94+
5095
### CLI Override
5196

5297
You can override the model and runtime options at launch:

internal/copilotconfig/mcp.go

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,38 @@
11
package copilotconfig
22

33
import (
4+
"encoding/json"
45
"fmt"
6+
"os"
7+
"path/filepath"
58
"strings"
69

710
copilot "github.com/github/copilot-sdk/go"
811
"github.com/go-viper/mapstructure/v2"
12+
"github.com/microsoft/waza/internal/mcpmock"
13+
"github.com/microsoft/waza/internal/models"
914
)
1015

1116
// ConvertMCPServers converts eval YAML mcp_servers entries into Copilot SDK MCP
1217
// configs. Invalid entries are skipped after emitting a warning through warnf.
1318
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+
}
1429
if len(serverConfigs) == 0 {
15-
return nil
30+
if len(mocks) == 0 {
31+
return nil
32+
}
1633
}
1734

18-
result := make(map[string]copilot.MCPServerConfig, len(serverConfigs))
35+
result := make(map[string]copilot.MCPServerConfig, len(serverConfigs)+len(mocks))
1936
for name, cfg := range serverConfigs {
2037
cfgMap, ok := cfg.(map[string]any)
2138
if !ok {
@@ -44,9 +61,92 @@ func ConvertMCPServers(serverConfigs map[string]any, warnf func(string, ...any))
4461
}
4562
}
4663

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+
}
4785
return result
4886
}
4987

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+
50150
func decode(input map[string]any, output any) error {
51151
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
52152
Result: output,

internal/copilotconfig/mcp_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package copilotconfig
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"testing"
9+
10+
copilot "github.com/github/copilot-sdk/go"
11+
"github.com/microsoft/waza/internal/mcpmock"
12+
"github.com/microsoft/waza/internal/models"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestConvertMCPServersWithMocks_AddsHermeticStdioServer(t *testing.T) {
17+
var warnings []string
18+
servers := ConvertMCPServersWithMocks(nil, []models.MCPMockConfig{{
19+
Name: "github",
20+
Tools: map[string]models.MCPMockTool{
21+
"list_issues": {
22+
Responses: []models.MCPMockResponse{{Match: map[string]any{"owner": "octocat"}, Return: map[string]any{"issues": []any{}}}},
23+
},
24+
},
25+
}}, t.TempDir(), func(format string, args ...any) {
26+
warnings = append(warnings, fmtString(format, args...))
27+
})
28+
29+
require.Empty(t, warnings)
30+
require.Contains(t, servers, "github")
31+
stdio, ok := servers["github"].(copilot.MCPStdioServerConfig)
32+
require.True(t, ok)
33+
require.NotEmpty(t, stdio.Command)
34+
require.Equal(t, "1", stdio.Env["WAZA_NO_UPDATE_CHECK"])
35+
require.Len(t, stdio.Args, 3)
36+
require.Equal(t, "__mcp-mock", stdio.Args[0])
37+
require.Equal(t, "--config-file", stdio.Args[1])
38+
39+
data, err := os.ReadFile(stdio.Args[2])
40+
require.NoError(t, err)
41+
t.Cleanup(func() { _ = os.Remove(stdio.Args[2]) })
42+
var cfg mcpmock.Config
43+
require.NoError(t, json.Unmarshal(data, &cfg))
44+
require.Equal(t, "github", cfg.Name)
45+
require.Contains(t, cfg.Tools, "list_issues")
46+
}
47+
48+
func TestConvertMCPServersWithMocks_PreservesRegularServers(t *testing.T) {
49+
servers := ConvertMCPServersWithMocks(map[string]any{
50+
"regular": map[string]any{"type": "stdio", "command": "echo"},
51+
}, nil, "", nil)
52+
53+
require.Contains(t, servers, "regular")
54+
_, ok := servers["regular"].(copilot.MCPStdioServerConfig)
55+
require.True(t, ok)
56+
}
57+
58+
func TestConvertMCPServersWithMocks_InvalidMockDisablesLiveServerFallback(t *testing.T) {
59+
var warnings []string
60+
servers := ConvertMCPServersWithMocks(map[string]any{
61+
"github": map[string]any{"type": "stdio", "command": "echo"},
62+
}, []models.MCPMockConfig{{
63+
Name: " github ",
64+
Fixtures: "missing",
65+
}}, t.TempDir(), func(format string, args ...any) {
66+
warnings = append(warnings, fmtString(format, args...))
67+
})
68+
69+
require.NotEmpty(t, warnings)
70+
require.NotContains(t, servers, "github")
71+
}
72+
73+
func fmtString(format string, args ...any) string {
74+
return strings.TrimSpace(fmt.Sprintf(format, args...))
75+
}

0 commit comments

Comments
 (0)