Skip to content

Commit e55c5ae

Browse files
zirainnacx
andauthored
mcp: setup benchmark test for MCPProxy (envoyproxy#1593)
**Description** This PR create a benchmark test for AIGW withe diffrent `mcp-session-encryption-iterations` Run this benchmark with following: ```console go test -timeout=15m -run='^$$' -bench=. -benchmem -benchtime=1x ./tests/bench/... 2025/12/03 13:50:12 starting DUMB MCP Streamable-HTTP server on :8080 at /mcp goos: darwin goarch: arm64 pkg: github.com/envoyproxy/ai-gateway/tests/bench cpu: Apple M1 Pro BenchmarkMCP/BaseLine-10 1 31305583 ns/op 49736 B/op 720 allocs/op BenchmarkMCP/Iterations_100-10 1 1394125 ns/op 22640 B/op 300 allocs/op PASS ok github.com/envoyproxy/ai-gateway/tests/bench 5.394s ``` ~~This result that the default `SessionCrypto` cost a lot, we'd better improve it or change the default setting.~~ cc @nacx --------- Signed-off-by: zirain <[email protected]> Signed-off-by: Ignasi Barrera <[email protected]> Co-authored-by: Ignasi Barrera <[email protected]> Co-authored-by: Ignasi Barrera <[email protected]>
1 parent 2c64a87 commit e55c5ae

File tree

4 files changed

+303
-8
lines changed

4 files changed

+303
-8
lines changed

cmd/aigw/run.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,15 @@ func run(ctx context.Context, c cmdRun, o *runOpts, stdout, stderr io.Writer) er
138138
// Do the translation of the given AI Gateway resources Yaml into Envoy Gateway resources and write them to the file.
139139
resourcesBuf := &bytes.Buffer{}
140140
runCtx := &runCmdContext{
141-
isDebug: c.Debug,
142-
envoyGatewayResourcesOut: resourcesBuf,
143-
stderrLogger: debugLogger,
144-
stderr: stderr,
145-
tmpdir: filepath.Dir(o.logPath), // runDir
146-
udsPath: o.extprocUDSPath,
147-
adminPort: c.AdminPort,
148-
extProcLauncher: o.extProcLauncher,
141+
isDebug: c.Debug,
142+
envoyGatewayResourcesOut: resourcesBuf,
143+
stderrLogger: debugLogger,
144+
stderr: stderr,
145+
tmpdir: filepath.Dir(o.logPath), // runDir
146+
udsPath: o.extprocUDSPath,
147+
adminPort: c.AdminPort,
148+
extProcLauncher: o.extProcLauncher,
149+
mcpSessionEncryptionIterations: c.MCPSessionEncryptionIterations,
149150
}
150151
// If any of the configured MCP servers is using stdio, set up the streamable HTTP proxies for them
151152
if err = proxyStdioMCPServers(ctx, debugLogger, c.mcpConfig); err != nil {

tests/bench/aigw.yaml

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright Envoy AI Gateway Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
# The full text of the Apache license is available in the LICENSE file at
4+
# the root of the repo.
5+
6+
---
7+
apiVersion: aigateway.envoyproxy.io/v1alpha1
8+
kind: MCPRoute
9+
metadata:
10+
name: mcp-route
11+
namespace: default
12+
spec:
13+
parentRefs:
14+
- name: aigw-run
15+
kind: Gateway
16+
group: gateway.networking.k8s.io
17+
path: "/mcp"
18+
backendRefs:
19+
- name: test
20+
kind: Backend
21+
group: gateway.envoyproxy.io
22+
path: "/mcp"
23+
---
24+
###################################################################################
25+
############################### Backend Definitions ###############################
26+
###################################################################################
27+
apiVersion: gateway.envoyproxy.io/v1alpha1
28+
kind: Backend
29+
metadata:
30+
name: test
31+
namespace: default
32+
spec:
33+
endpoints:
34+
- ip:
35+
address: 127.0.0.1
36+
port: 8080
37+
---
38+
###################################################################################
39+
############################### Gateway Definitions ###############################
40+
###################################################################################
41+
apiVersion: gateway.networking.k8s.io/v1
42+
kind: GatewayClass
43+
metadata:
44+
name: aigw-run
45+
spec:
46+
controllerName: gateway.envoyproxy.io/gatewayclass-controller
47+
---
48+
apiVersion: gateway.networking.k8s.io/v1
49+
kind: Gateway
50+
metadata:
51+
name: aigw-run
52+
namespace: default
53+
spec:
54+
gatewayClassName: aigw-run
55+
listeners:
56+
- name: http
57+
protocol: HTTP
58+
port: 1975
59+
infrastructure:
60+
parametersRef:
61+
group: gateway.envoyproxy.io
62+
kind: EnvoyProxy
63+
name: envoy-ai-gateway
64+
---
65+
apiVersion: gateway.envoyproxy.io/v1alpha1
66+
kind: EnvoyProxy
67+
metadata:
68+
name: envoy-ai-gateway
69+
namespace: default
70+
spec:
71+
logging:
72+
level:
73+
default: error
74+
bootstrap:
75+
type: Merge
76+
value: |-
77+
admin:
78+
address:
79+
socket_address:
80+
address: 127.0.0.1
81+
port_value: 9901
82+
telemetry:
83+
accessLog:
84+
settings:
85+
- sinks:
86+
- type: File
87+
file:
88+
path: /dev/stdout
89+
format:
90+
type: JSON
91+
json:
92+
# MCP specific fields
93+
mcp_request_id: "%DYNAMIC_METADATA(io.envoy.ai_gateway:mcp_request_id)%"
94+
mcp_session_id: "%REQ(MCP-SESSION-ID)%"
95+
mcp_method: "%DYNAMIC_METADATA(io.envoy.ai_gateway:mcp_method)%"
96+
mcp_backend: "%DYNAMIC_METADATA(io.envoy.ai_gateway:mcp_backend)%"
97+
# Default fields
98+
start_time: "%START_TIME%"
99+
method: "%REQ(:METHOD)%"
100+
x-envoy-origin-path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
101+
protocol: "%PROTOCOL%"
102+
response_code: "%RESPONSE_CODE%"
103+
response_flags: "%RESPONSE_FLAGS%"
104+
response_code_details: "%RESPONSE_CODE_DETAILS%"
105+
connection_termination_details: "%CONNECTION_TERMINATION_DETAILS%"
106+
upstream_transport_failure_reason: "%UPSTREAM_TRANSPORT_FAILURE_REASON%"
107+
bytes_received: "%BYTES_RECEIVED%"
108+
bytes_sent: "%BYTES_SENT%"
109+
duration: "%DURATION%"
110+
x-envoy-upstream-service-time: "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%"
111+
x-forwarded-for: "%REQ(X-FORWARDED-FOR)%"
112+
user-agent: "%REQ(USER-AGENT)%"
113+
x-request-id: "%REQ(X-REQUEST-ID)%"
114+
":authority": "%REQ(:AUTHORITY)%"
115+
upstream_host: "%UPSTREAM_HOST%"
116+
upstream_cluster: "%UPSTREAM_CLUSTER%"
117+
upstream_local_address: "%UPSTREAM_LOCAL_ADDRESS%"
118+
downstream_local_address: "%DOWNSTREAM_LOCAL_ADDRESS%"
119+
downstream_remote_address: "%DOWNSTREAM_REMOTE_ADDRESS%"
120+
requested_server_name: "%REQUESTED_SERVER_NAME%"
121+
route_name: "%ROUTE_NAME%"
122+
---
123+

tests/bench/bench_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
// 1. Build AIGW
7+
// make clean build.aigw
8+
// 2. Run the bench test
9+
// go test -timeout=15m -run='^$' -bench=. ./tests/bench/...
10+
11+
package bench
12+
13+
import (
14+
"context"
15+
_ "embed"
16+
"fmt"
17+
"net"
18+
"net/url"
19+
"os/exec"
20+
"runtime"
21+
"strings"
22+
"syscall"
23+
"testing"
24+
"time"
25+
26+
"github.com/modelcontextprotocol/go-sdk/mcp"
27+
"github.com/stretchr/testify/require"
28+
29+
"github.com/envoyproxy/ai-gateway/tests/internal/testmcp"
30+
)
31+
32+
const (
33+
writeTimeout = 120 * time.Second
34+
mcpServerPort = 8080
35+
aigwPort = 1975
36+
)
37+
38+
var aigwBinary = fmt.Sprintf("../../out/aigw-%s-%s", runtime.GOOS, runtime.GOARCH)
39+
40+
type MCPBenchCase struct {
41+
Name string
42+
ProxyBinary string
43+
ProxyArgs []string
44+
TestAddr string
45+
}
46+
47+
// setupBenchmark sets up the client connection.
48+
func setupBenchmark(b *testing.B) []MCPBenchCase {
49+
b.Helper() // Treat this as a helper function
50+
51+
// setup MCP server
52+
mcpServer := testmcp.NewServer(&testmcp.Options{
53+
Port: mcpServerPort,
54+
ForceJSONResponse: false,
55+
DumbEchoServer: true,
56+
WriteTimeout: writeTimeout,
57+
DisableLog: true,
58+
})
59+
b.Cleanup(func() {
60+
_ = mcpServer.Close()
61+
})
62+
63+
return []MCPBenchCase{
64+
{
65+
Name: "Baseline_NoProxy",
66+
TestAddr: fmt.Sprintf("http://localhost:%d", mcpServerPort),
67+
},
68+
{
69+
Name: "EAIGW_Default",
70+
TestAddr: fmt.Sprintf("http://localhost:%d/mcp", aigwPort),
71+
ProxyBinary: aigwBinary,
72+
ProxyArgs: []string{"run", "./aigw.yaml"},
73+
},
74+
{
75+
Name: "EAIGW_Config_100",
76+
TestAddr: fmt.Sprintf("http://localhost:%d/mcp", aigwPort),
77+
ProxyBinary: aigwBinary,
78+
ProxyArgs: []string{"run", "./aigw.yaml", "--mcp-session-encryption-iterations=100"},
79+
},
80+
{
81+
Name: "EAIGW_Inline_100",
82+
TestAddr: fmt.Sprintf("http://localhost:%d/mcp", aigwPort),
83+
ProxyBinary: aigwBinary,
84+
ProxyArgs: []string{
85+
"run",
86+
"--mcp-session-encryption-iterations=100",
87+
`--mcp-json={"mcpServers":{"aigw":{"type":"http","url":"http://localhost:8080/mcp"}}}`,
88+
},
89+
},
90+
}
91+
}
92+
93+
func BenchmarkMCP(b *testing.B) {
94+
cases := setupBenchmark(b)
95+
for _, tc := range cases {
96+
var proxy *exec.Cmd
97+
if tc.ProxyBinary != "" {
98+
proxy = startProxy(b, &tc)
99+
}
100+
101+
b.Run(tc.Name, func(b *testing.B) {
102+
mcpClient := mcp.NewClient(&mcp.Implementation{Name: "bench-http-client", Version: "0.1.0"}, nil)
103+
cs, err := mcpClient.Connect(b.Context(), &mcp.StreamableClientTransport{Endpoint: tc.TestAddr}, nil)
104+
if err != nil {
105+
b.Fatalf("Failed to connect server: %v", err)
106+
}
107+
108+
tools, err := cs.ListTools(b.Context(), &mcp.ListToolsParams{})
109+
if err != nil {
110+
b.Fatalf("Failed to list tools: %v", err)
111+
}
112+
var toolName string
113+
for _, t := range tools.Tools {
114+
if strings.Contains(t.Name, "echo") {
115+
toolName = t.Name
116+
break
117+
}
118+
}
119+
if toolName == "" {
120+
b.Fatalf("no echo tool found")
121+
}
122+
123+
b.ResetTimer()
124+
for i := 0; i < b.N; i++ {
125+
ctx, cancel := context.WithTimeout(b.Context(), 5*time.Second)
126+
res, err := cs.CallTool(ctx, &mcp.CallToolParams{
127+
Name: toolName,
128+
Arguments: testmcp.ToolEchoArgs{Text: "hello MCP"},
129+
})
130+
cancel()
131+
if err != nil {
132+
b.Fatalf("MCP Tool call name %s failed at iteration %d: %v", toolName, i, err)
133+
}
134+
135+
txt, ok := res.Content[0].(*mcp.TextContent)
136+
if !ok {
137+
b.Fatalf("unexpected content type")
138+
}
139+
if txt.Text != "dumb echo: hello MCP" {
140+
b.Fatalf("unexpected text: %q", txt.Text)
141+
}
142+
}
143+
})
144+
145+
if proxy != nil && proxy.Process != nil {
146+
_ = syscall.Kill(-proxy.Process.Pid, syscall.SIGKILL)
147+
}
148+
}
149+
}
150+
151+
func startProxy(b testing.TB, tc *MCPBenchCase) *exec.Cmd {
152+
addr, err := url.Parse(tc.TestAddr)
153+
require.NoError(b, err)
154+
155+
cmd := exec.CommandContext(b.Context(), tc.ProxyBinary, tc.ProxyArgs...) // nolint: gosec
156+
// put into new process group so we can kill the entire process tree (and children)
157+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
158+
require.NoError(b, cmd.Start())
159+
160+
// Wait until we can connect to the proxy
161+
require.Eventually(b, func() bool {
162+
_, err = (&net.Dialer{}).DialContext(b.Context(), "tcp", addr.Host)
163+
return err == nil
164+
}, 30*time.Second, 500*time.Millisecond, "proxy %s did not become ready in time", tc.Name)
165+
166+
return cmd
167+
}

tests/internal/testmcp/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Options struct {
2626
Port int
2727
ForceJSONResponse, DumbEchoServer bool
2828
WriteTimeout time.Duration
29+
DisableLog bool
2930
}
3031

3132
// NewServer starts a demo MCP server with two tools: echo and sum.
@@ -130,6 +131,9 @@ func NewServer(opts *Options) *http.Server {
130131
WriteTimeout: opts.WriteTimeout,
131132
Handler: handler,
132133
ConnState: func(conn net.Conn, state http.ConnState) {
134+
if opts.DisableLog {
135+
return
136+
}
133137
log.Printf("MCP SERVER connection [%s] %s -> %s\n", state, conn.RemoteAddr(), conn.LocalAddr())
134138
},
135139
}

0 commit comments

Comments
 (0)