Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ skills/ Example skills
```yaml
name: my-eval
skill: my-skill
schemaVersion: "1.0"
schemaVersion: "1.1"
version: "1.0"

config:
Expand Down Expand Up @@ -988,6 +988,26 @@ hooks:
exit_codes: [0]
error_on_fail: false

mcp_mocks:
- name: github
tools:
list_issues:
input_schema:
type: object
required: [owner, repo]
responses:
- match:
owner: microsoft
repo: waza
return:
issues:
- number: 363
title: MCP server mocks for hermetic eval
- match_regex:
repo: "^waza-.*"
return:
issues: []

graders:
- type: text
name: pattern_check
Expand Down Expand Up @@ -1017,6 +1037,19 @@ tasks:

`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>`.

### MCP Mock Servers

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.

```yaml
schemaVersion: "1.1"
mcp_mocks:
- name: github
fixtures: fixtures/mcp/github
```

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.

### Custom Input Variables

Use the `inputs` section to define key-value variables available throughout your evaluation as `{{.Vars.key}}`:
Expand Down
40 changes: 40 additions & 0 deletions cmd/waza/cmd_mcp_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"

"github.com/microsoft/waza/internal/mcpmock"
"github.com/spf13/cobra"
)

func newMCPMockCommand() *cobra.Command {
var configBase64 string
cmd := &cobra.Command{
Use: "__mcp-mock",
Short: "Run an internal MCP mock server",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if configBase64 == "" {
return fmt.Errorf("--config-base64 is required")
}
data, err := base64.StdEncoding.DecodeString(configBase64)
if err != nil {
return fmt.Errorf("decode mock config: %w", err)
}
var cfg mcpmock.Config
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse mock config: %w", err)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
mcpmock.ServeStdio(context.Background(), &cfg, os.Stdin, os.Stdout, logger)
return nil
},
}
cmd.Flags().StringVar(&configBase64, "config-base64", "", "base64-encoded MCP mock config")
return cmd
}
1 change: 1 addition & 0 deletions cmd/waza/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ performance against predefined test cases.`,
cmd.AddCommand(newUpdateCommand())
cmd.AddCommand(newSpecCommand())
cmd.AddCommand(newMigrateCommand())
cmd.AddCommand(newMCPMockCommand())

return cmd
}
Expand Down
45 changes: 45 additions & 0 deletions docs/INTEGRATION-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,51 @@ config:
args: ["-y", "@azure/mcp", "server", "start"]
```

### Hermetic MCP Mock Servers

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"`.

```yaml
name: github-triage
skill: issue-triage
schemaVersion: "1.1"
version: "1.0"

config:
trials_per_task: 1
executor: copilot-sdk
model: claude-sonnet-4.6
timeout_seconds: 300

mcp_mocks:
- name: github
tools:
list_issues:
input_schema:
type: object
properties:
owner: { type: string }
repo: { type: string }
required: [owner, repo]
responses:
- match:
owner: microsoft
repo: waza
return:
issues:
- number: 363
title: MCP server mocks for hermetic eval
- match_schema:
type: object
required: [owner, repo]
error: "No matching issue fixture"

tasks:
- tasks/*.yaml
```

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.

### CLI Override

You can override the model and runtime options at launch:
Expand Down
60 changes: 58 additions & 2 deletions internal/copilotconfig/mcp.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
package copilotconfig

import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"

copilot "github.com/github/copilot-sdk/go"
"github.com/go-viper/mapstructure/v2"
"github.com/microsoft/waza/internal/mcpmock"
"github.com/microsoft/waza/internal/models"
)

// ConvertMCPServers converts eval YAML mcp_servers entries into Copilot SDK MCP
// configs. Invalid entries are skipped after emitting a warning through warnf.
func ConvertMCPServers(serverConfigs map[string]any, warnf func(string, ...any)) map[string]copilot.MCPServerConfig {
return ConvertMCPServersWithMocks(serverConfigs, nil, "", warnf)
}

// ConvertMCPServersWithMocks converts eval YAML mcp_servers entries and
// mcp_mocks entries into Copilot SDK MCP configs. Mock servers are exposed as
// hermetic stdio MCP servers backed by this waza binary.
func ConvertMCPServersWithMocks(serverConfigs map[string]any, mocks []models.MCPMockConfig, baseDir string, warnf func(string, ...any)) map[string]copilot.MCPServerConfig {
if warnf == nil {
warnf = func(string, ...any) {}
}
if len(serverConfigs) == 0 {
return nil
if len(mocks) == 0 {
return nil
}
}

result := make(map[string]copilot.MCPServerConfig, len(serverConfigs))
result := make(map[string]copilot.MCPServerConfig, len(serverConfigs)+len(mocks))
for name, cfg := range serverConfigs {
cfgMap, ok := cfg.(map[string]any)
if !ok {
Expand Down Expand Up @@ -44,9 +61,48 @@ func ConvertMCPServers(serverConfigs map[string]any, warnf func(string, ...any))
}
}

for _, mock := range mocks {
cfg, err := mcpmock.FromEvalConfig(mock, baseDir)
if err != nil {
warnf("Warning: mcp_mock %q config is invalid: %v, skipping\n", mock.Name, err)
continue
}
if _, exists := result[cfg.Name]; exists {
warnf("Warning: mcp_mock %q overrides an mcp_servers entry with the same name\n", cfg.Name)
}
stdio, err := mockServerConfig(*cfg)
if err != nil {
warnf("Warning: mcp_mock %q could not be configured: %v, skipping\n", cfg.Name, err)
continue
}
result[cfg.Name] = stdio
}
Comment thread
spboyer marked this conversation as resolved.

if len(result) == 0 {
return nil
}
return result
}

func mockServerConfig(cfg mcpmock.Config) (copilot.MCPStdioServerConfig, error) {
data, err := json.Marshal(cfg)
if err != nil {
return copilot.MCPStdioServerConfig{}, fmt.Errorf("marshal mock config: %w", err)
}
exe, err := os.Executable()
if err != nil {
return copilot.MCPStdioServerConfig{}, fmt.Errorf("resolve waza executable: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
return copilot.MCPStdioServerConfig{
Command: exe,
Args: []string{"__mcp-mock", "--config-base64", encoded},
Env: map[string]string{
"WAZA_NO_UPDATE_CHECK": "1",
},
}, nil
Comment thread
spboyer marked this conversation as resolved.
}

func decode(input map[string]any, output any) error {
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: output,
Expand Down
59 changes: 59 additions & 0 deletions internal/copilotconfig/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package copilotconfig

import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"

copilot "github.com/github/copilot-sdk/go"
"github.com/microsoft/waza/internal/mcpmock"
"github.com/microsoft/waza/internal/models"
"github.com/stretchr/testify/require"
)

func TestConvertMCPServersWithMocks_AddsHermeticStdioServer(t *testing.T) {
var warnings []string
servers := ConvertMCPServersWithMocks(nil, []models.MCPMockConfig{{
Name: "github",
Tools: map[string]models.MCPMockTool{
"list_issues": {
Responses: []models.MCPMockResponse{{Match: map[string]any{"owner": "octocat"}, Return: map[string]any{"issues": []any{}}}},
},
},
}}, t.TempDir(), func(format string, args ...any) {
warnings = append(warnings, fmtString(format, args...))
})

require.Empty(t, warnings)
require.Contains(t, servers, "github")
stdio, ok := servers["github"].(copilot.MCPStdioServerConfig)
require.True(t, ok)
require.NotEmpty(t, stdio.Command)
require.Equal(t, "1", stdio.Env["WAZA_NO_UPDATE_CHECK"])
require.Len(t, stdio.Args, 3)
require.Equal(t, "__mcp-mock", stdio.Args[0])
require.Equal(t, "--config-base64", stdio.Args[1])

data, err := base64.StdEncoding.DecodeString(stdio.Args[2])
require.NoError(t, err)
var cfg mcpmock.Config
require.NoError(t, json.Unmarshal(data, &cfg))
require.Equal(t, "github", cfg.Name)
require.Contains(t, cfg.Tools, "list_issues")
}

func TestConvertMCPServersWithMocks_PreservesRegularServers(t *testing.T) {
servers := ConvertMCPServersWithMocks(map[string]any{
"regular": map[string]any{"type": "stdio", "command": "echo"},
}, nil, "", nil)

require.Contains(t, servers, "regular")
_, ok := servers["regular"].(copilot.MCPStdioServerConfig)
require.True(t, ok)
}

func fmtString(format string, args ...any) string {
return strings.TrimSpace(fmt.Sprintf(format, args...))
}
Loading
Loading