Harden MCP transport timeouts and expand external MCP e2e tests
This commit is contained in:
parent
e6a44f78f0
commit
5c073feeba
3 changed files with 127 additions and 13 deletions
|
|
@ -20,6 +20,7 @@ import (
|
||||||
const (
|
const (
|
||||||
defaultMCPStartupTimeout = 8 * time.Second
|
defaultMCPStartupTimeout = 8 * time.Second
|
||||||
defaultMCPCallTimeout = 30 * time.Second
|
defaultMCPCallTimeout = 30 * time.Second
|
||||||
|
defaultMCPTerminateWait = 1 * time.Second
|
||||||
maxToolNameLength = 64
|
maxToolNameLength = 64
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -210,9 +211,7 @@ func (c *mcpClient) buildTransport() (mcp.Transport, error) {
|
||||||
tr := &mcp.CommandTransport{
|
tr := &mcp.CommandTransport{
|
||||||
Command: cmd,
|
Command: cmd,
|
||||||
}
|
}
|
||||||
if c.cfg.TerminateTimeoutMS > 0 {
|
tr.TerminateDuration = durationFromMS(c.cfg.TerminateTimeoutMS, defaultMCPTerminateWait)
|
||||||
tr.TerminateDuration = time.Duration(c.cfg.TerminateTimeoutMS) * time.Millisecond
|
|
||||||
}
|
|
||||||
return tr, nil
|
return tr, nil
|
||||||
case "streamable_http":
|
case "streamable_http":
|
||||||
if strings.TrimSpace(c.cfg.URL) == "" {
|
if strings.TrimSpace(c.cfg.URL) == "" {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ func TestMCPExternalPopularFilesystemCommand(t *testing.T) {
|
||||||
requireExternalMCPTests(t)
|
requireExternalMCPTests(t)
|
||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
rootCanonical, err := filepath.EvalSymlinks(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvalSymlinks(root) error: %v", err)
|
||||||
|
}
|
||||||
|
testFile := filepath.Join(rootCanonical, "hello.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("hello from filesystem mcp"), 0644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(testFile) error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -33,7 +42,7 @@ func TestMCPExternalPopularFilesystemCommand(t *testing.T) {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Transport: "command",
|
Transport: "command",
|
||||||
Command: "npx",
|
Command: "npx",
|
||||||
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", root},
|
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", rootCanonical},
|
||||||
ToolPrefix: "mcp_fs",
|
ToolPrefix: "mcp_fs",
|
||||||
StartupTimeoutMS: 30000,
|
StartupTimeoutMS: 30000,
|
||||||
CallTimeoutMS: 30000,
|
CallTimeoutMS: 30000,
|
||||||
|
|
@ -46,17 +55,29 @@ func TestMCPExternalPopularFilesystemCommand(t *testing.T) {
|
||||||
t.Fatalf("LoadMCPTools() error: %v", err)
|
t.Fatalf("LoadMCPTools() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tool := findToolByName(tools, "mcp_fs_list_allowed_directories")
|
listAllowedDirs := findToolByName(tools, "mcp_fs_list_allowed_directories")
|
||||||
if tool == nil {
|
if listAllowedDirs == nil {
|
||||||
t.Fatalf("missing tool mcp_fs_list_allowed_directories; got %v", toolNames(tools))
|
t.Fatalf("missing tool mcp_fs_list_allowed_directories; got %v", toolNames(tools))
|
||||||
}
|
}
|
||||||
|
readFile := findToolByName(tools, "mcp_fs_read_file")
|
||||||
|
if readFile == nil {
|
||||||
|
t.Fatalf("missing tool mcp_fs_read_file; got %v", toolNames(tools))
|
||||||
|
}
|
||||||
|
|
||||||
out, err := tool.Execute(ctx, map[string]interface{}{})
|
out, err := listAllowedDirs.Execute(ctx, map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute(list_allowed_directories) error: %v", err)
|
t.Fatalf("Execute(list_allowed_directories) error: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, root) {
|
if !strings.Contains(out, rootCanonical) {
|
||||||
t.Fatalf("expected allowed directory %q in output: %s", root, out)
|
t.Fatalf("expected allowed directory %q in output: %s", rootCanonical, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
readOut, err := readFile.Execute(ctx, map[string]interface{}{"path": testFile})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute(read_file) error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(readOut, "hello from filesystem mcp") {
|
||||||
|
t.Fatalf("expected file content in output, got: %s", readOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +128,7 @@ func TestMCPExternalPopularEverythingSSE(t *testing.T) {
|
||||||
requireExternalMCPTests(t)
|
requireExternalMCPTests(t)
|
||||||
|
|
||||||
port := pickFreePort(t)
|
port := pickFreePort(t)
|
||||||
cmd := startEverythingSSEServer(t, port)
|
cmd := startEverythingServer(t, port, "sse")
|
||||||
waitForTCPPort(t, fmt.Sprintf("127.0.0.1:%d", port), 15*time.Second)
|
waitForTCPPort(t, fmt.Sprintf("127.0.0.1:%d", port), 15*time.Second)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
|
@ -149,6 +170,52 @@ func TestMCPExternalPopularEverythingSSE(t *testing.T) {
|
||||||
_ = cmd
|
_ = cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCPExternalPopularEverythingStreamableHTTP(t *testing.T) {
|
||||||
|
requireExternalMCPTests(t)
|
||||||
|
|
||||||
|
port := pickFreePort(t)
|
||||||
|
cmd := startEverythingServer(t, port, "streamableHttp")
|
||||||
|
waitForTCPPort(t, fmt.Sprintf("127.0.0.1:%d", port), 15*time.Second)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cfg := config.MCPToolsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Servers: []config.MCPServerConfig{
|
||||||
|
{
|
||||||
|
Name: "everything-http",
|
||||||
|
Enabled: true,
|
||||||
|
Transport: "streamable_http",
|
||||||
|
URL: fmt.Sprintf("http://127.0.0.1:%d/mcp", port),
|
||||||
|
ToolPrefix: "mcp_http",
|
||||||
|
StartupTimeoutMS: 30000,
|
||||||
|
CallTimeoutMS: 30000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := LoadMCPTools(ctx, cfg, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMCPTools() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
echoTool := findToolByName(tools, "mcp_http_echo")
|
||||||
|
if echoTool == nil {
|
||||||
|
t.Fatalf("missing tool mcp_http_echo; got %v", toolNames(tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := echoTool.Execute(ctx, map[string]interface{}{"message": "hello from streamable-http"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute(echo) error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "hello from streamable-http") {
|
||||||
|
t.Fatalf("unexpected echo output: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cmd
|
||||||
|
}
|
||||||
|
|
||||||
func requireExternalMCPTests(t *testing.T) {
|
func requireExternalMCPTests(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if os.Getenv("PICOCLAW_RUN_EXTERNAL_MCP_TESTS") != "1" {
|
if os.Getenv("PICOCLAW_RUN_EXTERNAL_MCP_TESTS") != "1" {
|
||||||
|
|
@ -197,16 +264,16 @@ func waitForTCPPort(t *testing.T, addr string, timeout time.Duration) {
|
||||||
t.Fatalf("port %s did not become ready within %v", addr, timeout)
|
t.Fatalf("port %s did not become ready within %v", addr, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startEverythingSSEServer(t *testing.T, port int) *exec.Cmd {
|
func startEverythingServer(t *testing.T, port int, mode string) *exec.Cmd {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cmd := exec.Command("npx", "-y", "@modelcontextprotocol/server-everything", "sse")
|
cmd := exec.Command("npx", "-y", "@modelcontextprotocol/server-everything", mode)
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port))
|
cmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port))
|
||||||
cmd.Stdout = os.Stderr
|
cmd.Stdout = os.Stderr
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
t.Fatalf("start everything sse server: %v", err)
|
t.Fatalf("start everything %s server: %v", mode, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -167,6 +168,53 @@ func TestLoadMCPTools_InvalidServerAggregatesError(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildTransport_CommandTerminateDefaults(t *testing.T) {
|
||||||
|
client := newMCPClient(config.MCPServerConfig{
|
||||||
|
Name: "default-terminate",
|
||||||
|
Enabled: true,
|
||||||
|
Transport: "command",
|
||||||
|
Command: "sleep",
|
||||||
|
Args: []string{"1"},
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
tr, err := client.buildTransport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildTransport() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdTr, ok := tr.(*mcp.CommandTransport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("buildTransport() returned %T, want *mcp.CommandTransport", tr)
|
||||||
|
}
|
||||||
|
if cmdTr.TerminateDuration != defaultMCPTerminateWait {
|
||||||
|
t.Fatalf("TerminateDuration = %v, want %v", cmdTr.TerminateDuration, defaultMCPTerminateWait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTransport_CommandTerminateOverride(t *testing.T) {
|
||||||
|
client := newMCPClient(config.MCPServerConfig{
|
||||||
|
Name: "override-terminate",
|
||||||
|
Enabled: true,
|
||||||
|
Transport: "command",
|
||||||
|
Command: "sleep",
|
||||||
|
Args: []string{"1"},
|
||||||
|
TerminateTimeoutMS: 2500,
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
tr, err := client.buildTransport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildTransport() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdTr, ok := tr.(*mcp.CommandTransport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("buildTransport() returned %T, want *mcp.CommandTransport", tr)
|
||||||
|
}
|
||||||
|
if cmdTr.TerminateDuration != 2500*time.Millisecond {
|
||||||
|
t.Fatalf("TerminateDuration = %v, want %v", cmdTr.TerminateDuration, 2500*time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toolNames(tools []Tool) []string {
|
func toolNames(tools []Tool) []string {
|
||||||
out := make([]string, 0, len(tools))
|
out := make([]string, 0, len(tools))
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue