Merge pull request #2811 from afjcjsbx/fix/mcp-streamable-http-support

fix(mcp): support streamable HTTP alias, request-response mode and integration tests
This commit is contained in:
Mauro 2026-05-15 12:55:58 +02:00 committed by GitHub
commit 6817aa5311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1417 additions and 42 deletions

View file

@ -5,7 +5,18 @@ on:
branches: [ "main" ]
jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
build:
needs: integration
runs-on: ubuntu-latest
steps:
- name: Checkout

View file

@ -64,3 +64,13 @@ jobs:
- name: Run go test
run: go test -tags goolm,stdjson ./...
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh

View file

@ -73,10 +73,13 @@ make check # Full pre-commit check: deps + fmt + vet + test + docs consist
```bash
make test # Run all tests
make integration-test # Run Docker-backed integration suites
go test -run TestName -v ./pkg/session/ # Run a single test
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
```
Docker-backed integration suites are auto-discovered from [`integration/suites/`](integration/suites/). See [`integration/README.md`](integration/README.md) for the suite layout and the conventions used by CI.
### Code Style
```bash

View file

@ -1,4 +1,4 @@
.PHONY: all build install uninstall clean help test build-all lint-docs
.PHONY: all build install uninstall clean help test integration-test build-all lint-docs
# Build variables
BINARY_NAME=picoclaw
@ -379,6 +379,10 @@ test: generate
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
@cd web && make test
## integration-test: Run Docker-backed integration test suites
integration-test:
@bash ./scripts/run-integration-tests.sh
## fmt: Format Go code
fmt:
@$(GOLANGCI_LINT) fmt

View file

@ -74,7 +74,7 @@ func newAddCommand() *cobra.Command {
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http / streamable-http, or sse")
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
@ -173,7 +173,7 @@ func parseAddArgs(args []string) (addOptions, string, string, []string, bool, er
}
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
transport := config.NormalizeMCPTransportType(opts.Transport)
if transport == "" {
transport = "stdio"
}

View file

@ -296,6 +296,47 @@ func TestMCPAddHTTPServer(t *testing.T) {
assert.Empty(t, server.Command)
}
func TestMCPAddSupportsStreamableHTTPAlias(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"context7",
"--transport",
"streamable-http",
"https://mcp.context7.com/mcp",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
}
func TestSaveValidatedConfigNormalizesStreamableHTTPAlias(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cfg := config.DefaultConfig()
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
"context7": {
Enabled: true,
Type: "streamable-http",
URL: "https://mcp.context7.com/mcp",
},
}
require.NoError(t, saveValidatedConfig(cfg))
saved := readMCPConfig(t, configPath)
server := saved.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
assert.Equal(t, "streamable-http", cfg.Tools.MCP.Servers["context7"].Type)
}
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{

View file

@ -108,7 +108,9 @@ func saveValidatedConfig(cfg *config.Config) error {
return fmt.Errorf("config is nil")
}
data, err := json.Marshal(cfg)
normalizedCfg := normalizedConfigForSave(cfg)
data, err := json.Marshal(normalizedCfg)
if err != nil {
return fmt.Errorf("failed to serialize config: %w", err)
}
@ -117,13 +119,32 @@ func saveValidatedConfig(cfg *config.Config) error {
return err
}
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
if err := config.SaveConfig(internal.GetConfigPath(), normalizedCfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func normalizedConfigForSave(cfg *config.Config) *config.Config {
clone := *cfg
if cfg.Tools.MCP.Servers == nil {
return &clone
}
clone.Tools = cfg.Tools
clone.Tools.MCP = cfg.Tools.MCP
clone.Tools.MCP.Servers = make(map[string]config.MCPServerConfig, len(cfg.Tools.MCP.Servers))
for name, server := range cfg.Tools.MCP.Servers {
if server.Type != "" {
server.Type = config.NormalizeMCPTransportType(server.Type)
}
clone.Tools.MCP.Servers[name] = server
}
return &clone
}
func validateConfigDocument(data []byte) error {
var instance map[string]any
if err := json.Unmarshal(data, &instance); err != nil {
@ -156,17 +177,11 @@ func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
}
func inferTransportType(server config.MCPServerConfig) string {
switch server.Type {
case "stdio", "http", "sse":
return server.Type
transport := config.EffectiveMCPTransportType(server)
if transport == "" {
return "unknown"
}
if server.URL != "" {
return "sse"
}
if server.Command != "" {
return "stdio"
}
return "unknown"
return transport
}
func renderServerTarget(server config.MCPServerConfig) string {

View file

@ -117,7 +117,7 @@ Supported flags:
| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. |
| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. |
| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. |
| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. |
| `--transport`, `-t` | Transport type: `stdio` (default), `http` / `streamable-http`, or `sse`. |
| `--force`, `-f` | Overwrite an existing server entry without confirmation. |
| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. |
| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. |
@ -198,13 +198,15 @@ For `stdio`:
- `--header` is rejected
- `-- <command> [args...]` is supported and recommended for unambiguous parsing
For `http` / `sse`:
For `http` / `streamable-http` / `sse`:
- `<command-or-url>` must be a valid URL
- extra command args are rejected
- `--env` is rejected
- `--env-file` is rejected
- `--header` is supported and stored in `headers`
- `http` and `streamable-http` use streamable HTTP request-response mode
- `sse` uses the same streamable HTTP transport, but also enables the optional standalone SSE listener for server-initiated notifications
Overwrite behavior:

255
integration/README.md Normal file
View file

@ -0,0 +1,255 @@
# Integration Test Suites
This directory contains the integration test suites that CI runs before merging PRs and before building `main`.
These tests exist to catch regressions that are easy to miss with unit tests alone, especially when different PRs touch adjacent code paths and the breakage only appears once the pieces are wired together. Typical examples are:
- protocol compatibility across package boundaries
- request and response behavior over real transports
- subprocess, CLI, or container wiring
- configuration passed through environment variables
- startup, discovery, and teardown flows involving more than one component
## Two Layers of Integration Testing
PicoClaw currently uses two related mechanisms:
1. Go integration tests, usually in `*_integration_test.go` files and guarded by `//go:build integration`
2. Docker-backed suites in `integration/suites/` that start real dependencies and run one or more of those Go tests in CI
That distinction matters:
- a tagged Go integration test is the test implementation
- a Docker-backed suite is how we make that test reproducible and CI-safe
If a test should actively protect merges between PRs, it should be reachable from [`scripts/run-integration-tests.sh`](../scripts/run-integration-tests.sh), either by extending an existing suite or by adding a new one.
Some integration-tagged tests are intentionally opt-in and not part of the Docker suites. For example, real CLI smoke tests under `pkg/providers/cli/` depend on external binaries being installed locally. Those are useful for manual verification, but they do not gate PR merges.
## What CI Runs
The integration jobs in [`.github/workflows/pr.yml`](../.github/workflows/pr.yml) and [`.github/workflows/build.yml`](../.github/workflows/build.yml) both execute:
```bash
bash ./scripts/run-integration-tests.sh
```
The runner auto-discovers every suite under `integration/suites/`, so adding a suite does not require editing the GitHub Actions workflow.
## How the Runner Works
- The shared runner is defined in [`integration/docker-compose.runner.yml`](docker-compose.runner.yml).
- Each suite lives in `integration/suites/<suite-name>/`.
- The runner script loads `suite.env`, merges the shared compose file with the suite-specific compose files, starts dependency services, runs the suite command, and then tears everything down.
- The shared runner container sets `GOFLAGS=-tags=goolm,stdjson,integration`, so tests run with the same build tags used by CI.
In practice, each suite gives us:
- a deterministic dependency graph
- a stable execution environment
- automatic cleanup after the run
- a clean place to encode the exact regression we want to prevent
## Current Reference Suite
[`integration/suites/mcp-streamable/`](suites/mcp-streamable/) is the reference example today.
It does three things:
- builds and starts a fixture MCP server from [`integration/fixtures/mcp-streamable-server/`](fixtures/mcp-streamable-server/)
- injects connection details into the runner container through environment variables
- runs [`TestIntegration_RealConfiguredServer`](../pkg/mcp/manager_real_server_integration_test.go) to verify that PicoClaw can connect to a real server, discover tools, invoke one, and validate the response payload
That suite complements [`TestIntegration_StreamableHTTPCompatibility`](../pkg/mcp/manager_integration_test.go), which exercises the same area in-process. Together they cover both protocol behavior and real service wiring.
## Suite Layout
Each suite directory must contain:
- `suite.env`
- at least one `docker-compose.yml` or `docker-compose.*.yml`
Example:
```text
integration/suites/my-suite/
├── docker-compose.yml
└── suite.env
```
## Required Manifest Fields
`suite.env` is sourced by the runner script and must define:
- `TEST_COMMAND`: shell command executed inside the integration runner container
Optional fields:
- `RUNNER_SERVICE`: override the default runner service name (`integration-runner`)
Example:
```bash
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
```
## Running Integration Tests Locally
### Prerequisites
- Docker with the `docker compose` plugin for Docker-backed suites
- Go 1.25+ only if you want to run tagged integration tests directly on your host instead of through Docker
### Run Everything That CI Runs
```bash
make integration-test
```
Equivalent direct command:
```bash
bash ./scripts/run-integration-tests.sh
```
### Run a Single Suite
```bash
bash ./scripts/run-integration-tests.sh mcp-streamable
```
This is the fastest way to reproduce exactly what the CI integration job does for one suite.
### Run a Tagged Integration Test Directly
For faster iteration while writing the test, you can run the Go test itself without Docker:
```bash
go test -tags=goolm,stdjson,integration ./pkg/mcp -run TestIntegration_StreamableHTTPCompatibility -v
```
You can also run the real-server smoke test directly if you provide the same environment variables that the Docker suite would inject:
```bash
PICOCLAW_MCP_REAL_SERVER_JSON='{"enabled":true,"type":"http","url":"http://127.0.0.1:8080/mcp"}' \
PICOCLAW_MCP_REAL_TOOL_NAME=echo \
PICOCLAW_MCP_REAL_TOOL_ARGS_JSON='{"message":"hello"}' \
PICOCLAW_MCP_REAL_EXPECT_SUBSTRING=hello \
go test -tags=goolm,stdjson,integration ./pkg/mcp -run TestIntegration_RealConfiguredServer -v
```
Notes:
- avoid `-short`: the current integration tests skip in short mode
- use direct `go test` for tight feedback loops, then validate the Docker suite before committing
## When to Add an Integration Test
Reach for an integration test when the risk lives in the interaction, not just in the function body. Good candidates include:
- code that crosses process or container boundaries
- transport-specific behavior such as HTTP, SSE, stdio, or streamable MCP flows
- CLI parsing and wiring that depends on real subprocess execution
- configuration propagation through files, env vars, headers, or service discovery
- regressions that usually appear only after merging separately reasonable PRs
Prefer a unit test when the behavior is pure, local, and fully controllable in-process.
## How to Add a New Integration Test
### 1. Start from the regression you want to prevent
Describe the real workflow that could break after a merge. The sharper the scenario, the better the test will age.
Examples:
- "PicoClaw can still connect to a streamable MCP server after transport normalization changes."
- "A provider wrapper still parses the real CLI output format after refactors in response handling."
### 2. Implement the Go test
Add or extend a `*_integration_test.go` file in the package that owns the behavior and gate it with:
```go
//go:build integration
```
Guidelines:
- keep the assertions focused on observable behavior
- use bounded timeouts
- prefer deterministic fixtures over internet access or shared external state
- skip only when the dependency is intentionally optional, such as a locally installed third-party CLI
### 3. Decide whether it must gate CI merges
Use this rule of thumb:
- if the test is only a manual smoke check, a tagged Go test may be enough
- if the test should prevent regressions from landing through PR merges, wire it into a Docker-backed suite
### 4. Reuse or add a suite
If an existing suite already exercises the same subsystem, extend it. Otherwise create:
```text
integration/suites/<name>/
├── docker-compose.yml
└── suite.env
```
Use `integration/fixtures/` for reusable helper services or fake servers.
### 5. Define the suite command
In `suite.env`, point `TEST_COMMAND` at the Go test you want CI to run.
Examples:
```bash
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
```
```bash
TEST_COMMAND='go test ./pkg/somepkg -run TestIntegration_MyScenario -v'
```
You can also run multiple tests if they share the same environment, but keep suites cohesive and easy to diagnose when they fail.
### 6. Model the dependencies in Docker Compose
Suite compose files can:
- define dependency services needed by the tests
- extend or override the shared `integration-runner` service
- inject environment variables into the runner for the tests to consume
Practical advice:
- prefer Docker service names over hard-coded host ports
- add health checks when the runner depends on service readiness
- keep the suite self-contained and deterministic
### 7. Validate locally before committing
At minimum:
```bash
go test -tags=goolm,stdjson,integration ./path/to/package -run TestIntegration_Name -v
bash ./scripts/run-integration-tests.sh <suite-name>
```
The first command helps while authoring. The second proves that the CI path works end to end.
## Review Checklist for New Suites
Before opening the PR, check that the new suite:
- reproduces a realistic cross-component failure mode
- is deterministic and isolated
- does not require manual setup in CI
- has clear failure output
- finishes in a reasonable amount of time
- cleans up after itself through the normal runner teardown
Once committed, the suite will be auto-discovered by the CI integration job.

View file

@ -0,0 +1,19 @@
services:
integration-runner:
image: golang:1.25-bookworm
working_dir: /workspace
entrypoint: ["bash", "-c"]
volumes:
- ${INTEGRATION_REPO_ROOT}:/workspace
- picoclaw-integration-gocache:/go-build-cache
- picoclaw-integration-gomodcache:/go-mod-cache
environment:
GOCACHE: /go-build-cache
GOMODCACHE: /go-mod-cache
GOTOOLCHAIN: local
CGO_ENABLED: "0"
GOFLAGS: -tags=goolm,stdjson,integration
volumes:
picoclaw-integration-gocache:
picoclaw-integration-gomodcache:

View file

@ -0,0 +1,23 @@
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/mcp-streamable-server ./integration/fixtures/mcp-streamable-server
FROM alpine:3.22
RUN adduser -D -u 10001 appuser
COPY --from=build /out/mcp-streamable-server /usr/local/bin/mcp-streamable-server
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --retries=12 CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/mcp-streamable-server"]

View file

@ -0,0 +1,71 @@
package main
import (
"context"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
server := mcp.NewServer(&mcp.Implementation{
Name: "picoclaw-integration-streamable-server",
Version: "1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "echo",
Description: "Echo back the provided message",
}, func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
message, _ := args["message"].(string)
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: message},
},
}, nil, nil
})
streamable := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{
JSONResponse: envBool("STREAMABLE_JSON_RESPONSE", true),
})
mux := http.NewServeMux()
mux.Handle("/mcp", streamable)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("streamable MCP integration server listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func envBool(name string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}

View file

@ -0,0 +1,156 @@
package integration
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRunIntegrationTestsScriptExecutesSuiteCommand(t *testing.T) {
bashPath, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not available")
}
repoRoot := repoRootFromTestFile(t)
suitesRoot := filepath.Join(repoRoot, "integration", "suites")
suiteDir, err := os.MkdirTemp(suitesRoot, "runner-script-")
if err != nil {
t.Fatalf("MkdirTemp() error = %v", err)
}
t.Cleanup(func() {
_ = os.RemoveAll(suiteDir)
})
suiteName := filepath.Base(suiteDir)
err = os.WriteFile(
filepath.Join(suiteDir, "suite.env"),
[]byte("TEST_COMMAND='printf runner-ok'\n"),
0o644,
)
if err != nil {
t.Fatalf("WriteFile(suite.env) error = %v", err)
}
err = os.WriteFile(
filepath.Join(suiteDir, "docker-compose.yml"),
[]byte("services:\n fake-dependency:\n image: busybox\n"),
0o644,
)
if err != nil {
t.Fatalf("WriteFile(docker-compose.yml) error = %v", err)
}
stubDir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "docker.log")
err = os.WriteFile(filepath.Join(stubDir, "docker"), []byte(`#!/bin/sh
set -eu
log_file="${DOCKER_LOG:?}"
{
printf '%s\n' '---'
for arg in "$@"; do
printf '%s\n' "$arg"
done
} >>"$log_file"
subcommand=""
for arg in "$@"; do
case "$arg" in
config|up|run|down)
subcommand="$arg"
;;
esac
done
case "$subcommand" in
config)
printf '%s\n' integration-runner fake-dependency
;;
up)
;;
run)
printf '%s\n' runner-ok
;;
down)
;;
*)
printf 'unexpected docker invocation: %s\n' "$*" >&2
exit 1
;;
esac
`), 0o755)
if err != nil {
t.Fatalf("WriteFile(docker stub) error = %v", err)
}
cmd := exec.Command(bashPath, filepath.Join(repoRoot, "scripts", "run-integration-tests.sh"), suiteName)
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(),
"PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"DOCKER_LOG="+logPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("run-integration-tests.sh error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), "runner-ok") {
t.Fatalf("script output did not include runner output:\n%s", output)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(logPath) error = %v", err)
}
runArgs := findLoggedDockerInvocation(t, string(logData), "run")
if strings.Contains(strings.Join(runArgs, "\n"), "\nsh\n-c\n") {
t.Fatalf("docker compose run unexpectedly wrapped TEST_COMMAND with sh -c:\n%v", runArgs)
}
if !containsArg(runArgs, "integration-runner") {
t.Fatalf("docker compose run args missing runner service:\n%v", runArgs)
}
if !containsArg(runArgs, "printf runner-ok") {
t.Fatalf("docker compose run args missing suite command as a single argument:\n%v", runArgs)
}
}
func repoRootFromTestFile(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
return filepath.Dir(wd)
}
func findLoggedDockerInvocation(t *testing.T, logData, subcommand string) []string {
t.Helper()
for _, block := range strings.Split(logData, "---\n") {
block = strings.TrimSpace(block)
if block == "" {
continue
}
args := strings.Split(block, "\n")
if containsArg(args, subcommand) {
return args
}
}
t.Fatalf("did not find docker %q invocation in log:\n%s", subcommand, logData)
return nil
}
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}

View file

@ -0,0 +1,19 @@
services:
integration-runner:
depends_on:
mcp-streamable-server:
condition: service_healthy
environment:
PICOCLAW_MCP_REAL_SERVER_JSON: >-
{"enabled":true,"type":"http","url":"http://mcp-streamable-server:8080/mcp"}
PICOCLAW_MCP_REAL_TOOL_NAME: echo
PICOCLAW_MCP_REAL_TOOL_ARGS_JSON: >-
{"message":"hello from docker integration suite"}
PICOCLAW_MCP_REAL_EXPECT_SUBSTRING: hello from docker integration suite
mcp-streamable-server:
build:
context: ${INTEGRATION_REPO_ROOT}
dockerfile: integration/fixtures/mcp-streamable-server/Dockerfile
environment:
STREAMABLE_JSON_RESPONSE: "true"

View file

@ -0,0 +1 @@
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'

View file

@ -1126,7 +1126,11 @@ type MCPServerConfig struct {
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
// Type is "stdio", "sse", "http", or "streamable-http".
// "http" and "streamable-http" both select streamable HTTP request-response
// mode, while "sse" keeps the standalone SSE listener enabled for
// server-initiated notifications. Defaults: stdio if command is set, sse if
// url is set.
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`

View file

@ -0,0 +1,32 @@
package config
import "strings"
// NormalizeMCPTransportType canonicalizes MCP transport names used in config.
// "http" is PicoClaw's streamable HTTP request-response mode, and
// "streamable-http" is accepted as an explicit alias for the same transport.
func NormalizeMCPTransportType(transport string) string {
normalized := strings.ToLower(strings.TrimSpace(transport))
switch normalized {
case "streamable-http", "streamable_http", "streamablehttp":
return "http"
default:
return normalized
}
}
// EffectiveMCPTransportType returns the normalized configured transport, or the
// inferred default when the config leaves Type empty.
func EffectiveMCPTransportType(server MCPServerConfig) string {
if transport := NormalizeMCPTransportType(server.Type); transport != "" {
return transport
}
if server.URL != "" {
return "sse"
}
if server.Command != "" {
return "stdio"
}
return ""
}

View file

@ -79,14 +79,5 @@ func setMCPAttrString(attrs map[string]any, key, value string) {
}
func mcpTransportType(cfg config.MCPServerConfig) string {
if cfg.Type != "" {
return cfg.Type
}
if cfg.URL != "" {
return "sse"
}
if cfg.Command != "" {
return "stdio"
}
return ""
return config.EffectiveMCPTransportType(cfg)
}

View file

@ -342,17 +342,9 @@ func connectServer(
// Create transport based on configuration
// Auto-detect transport type if not explicitly specified
var transport mcp.Transport
transportType := cfg.Type
// Auto-detect: if URL is provided, use SSE; if command is provided, use stdio
transportType := config.EffectiveMCPTransportType(cfg)
if transportType == "" {
if cfg.URL != "" {
transportType = "sse"
} else if cfg.Command != "" {
transportType = "stdio"
} else {
return nil, fmt.Errorf("either URL or command must be provided")
}
return nil, fmt.Errorf("either URL or command must be provided")
}
switch transportType {
@ -362,12 +354,13 @@ func connectServer(
}
// Configure DisableStandaloneSSE based on transport type.
// - "http": Request-response only mode. Disable the standalone SSE stream
// to avoid compatibility issues with servers that don't support GET /mcp.
// - "http": Streamable HTTP request-response mode. Disable the standalone
// SSE stream to avoid compatibility issues with servers that don't
// support the optional GET listener.
// - "sse": Bidirectional mode. Enable the standalone SSE stream to receive
// server-initiated notifications (e.g., ToolListChangedNotification).
// - Empty or auto-detected: Defaults to "sse" behavior (standalone SSE enabled).
disableStandaloneSSE := (cfg.Type == "http")
disableStandaloneSSE := transportType == "http"
logger.DebugCF("mcp", "Using SSE/HTTP transport",
map[string]any{
@ -452,7 +445,7 @@ func connectServer(
transport = &isolatedCommandTransport{Command: cmd}
default:
return nil, fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",
"unsupported transport type: %s (supported: stdio, sse, http, streamable-http)",
transportType,
)
}

View file

@ -0,0 +1,326 @@
//go:build integration
package mcp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
)
// Run with: go test -tags=integration ./pkg/mcp
func TestIntegration_StreamableHTTPCompatibility(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
tests := []struct {
name string
transportType string
jsonResponse bool
rejectStandaloneGET bool
wantResponseContentType string
}{
{
name: "http/json-only-without-get-listener",
transportType: "http",
jsonResponse: true,
rejectStandaloneGET: true,
wantResponseContentType: "application/json",
},
{
name: "http/streaming-post-responses",
transportType: "http",
jsonResponse: false,
rejectStandaloneGET: false,
wantResponseContentType: "text/event-stream",
},
{
name: "streamable-http-alias/json-only-without-get-listener",
transportType: "streamable-http",
jsonResponse: true,
rejectStandaloneGET: true,
wantResponseContentType: "application/json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server, recorder := newRecordedGoSDKStreamableServer(t, tt.jsonResponse, tt.rejectStandaloneGET)
defer server.Close()
mgr := NewManager()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := mgr.ConnectServer(ctx, "compat", config.MCPServerConfig{
Enabled: true,
Type: tt.transportType,
URL: server.URL,
Headers: map[string]string{
"Authorization": "Bearer integration-token",
},
})
if err != nil {
t.Fatalf("ConnectServer() error = %v", err)
}
tools := mgr.GetAllTools()
if got := len(tools["compat"]); got != 1 {
t.Fatalf("len(GetAllTools()[\"compat\"]) = %d, want 1", got)
}
result, err := mgr.CallTool(ctx, "compat", "echo", map[string]any{
"message": "hello from integration",
})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got, want := extractTextResult(t, result), "hello from integration"; got != want {
t.Fatalf("CallTool() text = %q, want %q", got, want)
}
if err := mgr.Close(); err != nil {
t.Fatalf("Manager.Close() error = %v", err)
}
assertRecordedCompatibility(t, recorder.snapshot(), tt.wantResponseContentType)
})
}
}
type recordedRequest struct {
Method string
Path string
JSONRPCMethod string
RequestSessionID string
Authorization string
ResponseStatusCode int
ResponseContentType string
}
type requestRecorder struct {
mu sync.Mutex
requests []recordedRequest
}
func (r *requestRecorder) add(req recordedRequest) {
r.mu.Lock()
defer r.mu.Unlock()
r.requests = append(r.requests, req)
}
func (r *requestRecorder) snapshot() []recordedRequest {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedRequest, len(r.requests))
copy(out, r.requests)
return out
}
func newRecordedGoSDKStreamableServer(
t *testing.T,
jsonResponse bool,
rejectStandaloneGET bool,
) (*httptest.Server, *requestRecorder) {
t.Helper()
server := sdkmcp.NewServer(&sdkmcp.Implementation{
Name: "streamable-integration-server",
Version: "1.0.0",
}, nil)
sdkmcp.AddTool(server, &sdkmcp.Tool{
Name: "echo",
Description: "Echo a message",
}, func(ctx context.Context, req *sdkmcp.CallToolRequest, args map[string]any) (*sdkmcp.CallToolResult, any, error) {
message, _ := args["message"].(string)
return &sdkmcp.CallToolResult{
Content: []sdkmcp.Content{
&sdkmcp.TextContent{Text: message},
},
}, nil, nil
})
recorder := &requestRecorder{}
handler := sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
return server
}, &sdkmcp.StreamableHTTPOptions{
JSONResponse: jsonResponse,
})
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rejectStandaloneGET && r.Method == http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
recorder.add(recordedRequest{
Method: r.Method,
Path: r.URL.Path,
RequestSessionID: r.Header.Get("Mcp-Session-Id"),
Authorization: r.Header.Get("Authorization"),
ResponseStatusCode: http.StatusMethodNotAllowed,
ResponseContentType: normalizeContentType(w.Header().Get("Content-Type")),
})
return
}
recorded := recordedRequest{
Method: r.Method,
Path: r.URL.Path,
RequestSessionID: r.Header.Get("Mcp-Session-Id"),
Authorization: r.Header.Get("Authorization"),
}
if r.Method == http.MethodPost {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("reading request body: %v", err)
}
r.Body = io.NopCloser(bytes.NewReader(body))
var envelope struct {
Method string `json:"method"`
}
if err := json.Unmarshal(body, &envelope); err == nil {
recorded.JSONRPCMethod = envelope.Method
}
}
rw := &recordingResponseWriter{ResponseWriter: w}
handler.ServeHTTP(rw, r)
recorded.ResponseStatusCode = rw.statusCode()
recorded.ResponseContentType = normalizeContentType(rw.Header().Get("Content-Type"))
recorder.add(recorded)
}))
return httpServer, recorder
}
type recordingResponseWriter struct {
http.ResponseWriter
status int
}
func (w *recordingResponseWriter) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
func (w *recordingResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(p)
}
func (w *recordingResponseWriter) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *recordingResponseWriter) statusCode() int {
if w.status != 0 {
return w.status
}
return http.StatusOK
}
func extractTextResult(t *testing.T, result *sdkmcp.CallToolResult) string {
t.Helper()
if result == nil || len(result.Content) != 1 {
t.Fatalf("unexpected CallToolResult: %#v", result)
}
text, ok := result.Content[0].(*sdkmcp.TextContent)
if !ok {
t.Fatalf("CallToolResult content type = %T, want *sdkmcp.TextContent", result.Content[0])
}
return text.Text
}
func assertRecordedCompatibility(
t *testing.T,
requests []recordedRequest,
wantResponseContentType string,
) {
t.Helper()
var (
getCount int
deleteCount int
postWithSession int
deleteWithSession int
requestsMissingAuth []string
observedContentTypesByMethod = map[string]string{}
)
for _, req := range requests {
switch req.Method {
case http.MethodGet:
getCount++
case http.MethodPost:
if req.RequestSessionID != "" {
postWithSession++
}
if req.JSONRPCMethod != "" && observedContentTypesByMethod[req.JSONRPCMethod] == "" {
observedContentTypesByMethod[req.JSONRPCMethod] = req.ResponseContentType
}
case http.MethodDelete:
deleteCount++
if req.RequestSessionID != "" {
deleteWithSession++
}
}
if req.Authorization != "Bearer integration-token" {
requestsMissingAuth = append(requestsMissingAuth, req.Method+" "+req.Path)
}
}
if getCount != 0 {
t.Fatalf("expected no standalone GET requests for streamable HTTP mode, saw %d", getCount)
}
if deleteCount != 1 {
t.Fatalf("DELETE count = %d, want 1", deleteCount)
}
if postWithSession == 0 {
t.Fatal("expected at least one POST request with Mcp-Session-Id")
}
if deleteWithSession != 1 {
t.Fatalf("expected exactly one DELETE with Mcp-Session-Id, got %d", deleteWithSession)
}
if len(requestsMissingAuth) > 0 {
t.Fatalf("Authorization header missing on requests: %v", requestsMissingAuth)
}
for _, method := range []string{"initialize", "tools/list", "tools/call"} {
if observedContentTypesByMethod[method] == "" {
t.Fatalf("did not observe POST response for JSON-RPC method %q", method)
}
if observedContentTypesByMethod[method] != wantResponseContentType {
t.Fatalf(
"response content-type for %s = %q, want %q",
method,
observedContentTypesByMethod[method],
wantResponseContentType,
)
}
}
}
func normalizeContentType(value string) string {
return strings.TrimSpace(strings.SplitN(value, ";", 2)[0])
}

View file

@ -0,0 +1,148 @@
//go:build integration
package mcp
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"testing"
"time"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
)
// TestIntegration_RealConfiguredServer is an opt-in smoke test for a real MCP
// server configured via environment variables.
//
// Run with:
//
// go test -tags=integration ./pkg/mcp -run TestIntegration_RealConfiguredServer -v
//
// Minimum configuration:
//
// PICOCLAW_MCP_REAL_SERVER_JSON='{"enabled":true,"type":"http","url":"http://127.0.0.1:8080/mcp"}'
//
// Optional tool invocation:
//
// PICOCLAW_MCP_REAL_TOOL_NAME=echo
// PICOCLAW_MCP_REAL_TOOL_ARGS_JSON='{"message":"hello"}'
// PICOCLAW_MCP_REAL_EXPECT_SUBSTRING=hello
//
// Stdio subprocess example:
//
// PICOCLAW_MCP_REAL_SERVER_JSON='{"enabled":true,"type":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","."]}'
func TestIntegration_RealConfiguredServer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
serverJSON := strings.TrimSpace(os.Getenv("PICOCLAW_MCP_REAL_SERVER_JSON"))
if serverJSON == "" {
t.Skip("skipping integration test (set PICOCLAW_MCP_REAL_SERVER_JSON to enable)")
}
serverCfg, err := loadRealServerConfig(serverJSON)
if err != nil {
t.Fatalf("loadRealServerConfig() error = %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
mgr := NewManager()
if err := mgr.ConnectServer(ctx, "real", serverCfg); err != nil {
t.Fatalf("ConnectServer() error = %v", err)
}
defer func() {
if err := mgr.Close(); err != nil {
t.Errorf("Manager.Close() error = %v", err)
}
}()
tools := mgr.GetAllTools()["real"]
if len(tools) == 0 {
t.Fatal("expected at least one discovered tool from real MCP server")
}
t.Logf("connected to real MCP server via %s with %d tool(s)", config.EffectiveMCPTransportType(serverCfg), len(tools))
for _, tool := range tools {
if tool != nil {
t.Logf("discovered tool: %s", tool.Name)
}
}
if expectedCountRaw := strings.TrimSpace(os.Getenv("PICOCLAW_MCP_REAL_EXPECT_TOOL_COUNT")); expectedCountRaw != "" {
expectedCount, err := strconv.Atoi(expectedCountRaw)
if err != nil {
t.Fatalf("invalid PICOCLAW_MCP_REAL_EXPECT_TOOL_COUNT %q: %v", expectedCountRaw, err)
}
if len(tools) != expectedCount {
t.Fatalf("tool count = %d, want %d", len(tools), expectedCount)
}
}
toolName := strings.TrimSpace(os.Getenv("PICOCLAW_MCP_REAL_TOOL_NAME"))
if toolName == "" {
return
}
toolArgs, err := loadRealToolArgs(os.Getenv("PICOCLAW_MCP_REAL_TOOL_ARGS_JSON"))
if err != nil {
t.Fatalf("loadRealToolArgs() error = %v", err)
}
result, err := mgr.CallTool(ctx, "real", toolName, toolArgs)
if err != nil {
t.Fatalf("CallTool(%q) error = %v", toolName, err)
}
textPayload := joinTextContents(result)
t.Logf("tool %q returned text payload: %q", toolName, textPayload)
if want := os.Getenv("PICOCLAW_MCP_REAL_EXPECT_SUBSTRING"); want != "" && !strings.Contains(textPayload, want) {
t.Fatalf("tool result %q does not contain expected substring %q", textPayload, want)
}
}
func loadRealServerConfig(raw string) (config.MCPServerConfig, error) {
var cfg config.MCPServerConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return config.MCPServerConfig{}, err
}
if !cfg.Enabled {
cfg.Enabled = true
}
return cfg, nil
}
func loadRealToolArgs(raw string) (map[string]any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return map[string]any{}, nil
}
var args map[string]any
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return nil, err
}
return args, nil
}
func joinTextContents(result *sdkmcp.CallToolResult) string {
if result == nil || len(result.Content) == 0 {
return ""
}
parts := make([]string, 0, len(result.Content))
for _, content := range result.Content {
if text, ok := content.(*sdkmcp.TextContent); ok && text != nil {
parts = append(parts, text.Text)
}
}
return strings.Join(parts, "\n")
}

View file

@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@ -408,6 +410,133 @@ func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {
})
}
func TestConnectServer_StreamableHTTPRequestResponseMode(t *testing.T) {
t.Parallel()
for _, transportType := range []string{"http", "streamable-http"} {
t.Run(transportType, func(t *testing.T) {
t.Parallel()
server := sdkmcp.NewServer(&sdkmcp.Implementation{
Name: "streamable-test-server",
Version: "1.0.0",
}, nil)
sdkmcp.AddTool(server, &sdkmcp.Tool{
Name: "echo",
Description: "Echo test tool",
}, func(ctx context.Context, req *sdkmcp.CallToolRequest, args map[string]any) (*sdkmcp.CallToolResult, any, error) {
return &sdkmcp.CallToolResult{
Content: []sdkmcp.Content{
&sdkmcp.TextContent{Text: "ok"},
},
}, nil, nil
})
type observedRequest struct {
Method string
SessionID string
Authorization string
}
var (
mu sync.Mutex
observed []observedRequest
)
handler := sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
return server
}, nil)
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
observed = append(observed, observedRequest{
Method: r.Method,
SessionID: r.Header.Get("Mcp-Session-Id"),
Authorization: r.Header.Get("Authorization"),
})
mu.Unlock()
handler.ServeHTTP(w, r)
}))
defer httpServer.Close()
conn, err := connectServer(context.Background(), "streamable", config.MCPServerConfig{
Enabled: true,
Type: transportType,
URL: httpServer.URL,
Headers: map[string]string{
"Authorization": "Bearer test-token",
},
})
if err != nil {
t.Fatalf("connectServer(%q) error = %v", transportType, err)
}
if got := len(conn.Tools); got != 1 {
t.Fatalf("len(conn.Tools) = %d, want 1", got)
}
if got := conn.Session.ID(); got == "" {
t.Fatal("expected non-empty streamable session ID")
}
if err := conn.Session.Close(); err != nil {
t.Fatalf("Session.Close() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
var (
getCount int
postCount int
deleteCount int
postWithSession bool
deleteWithSession bool
requestsWithAuth int
requestsWithoutAuth []string
)
for _, req := range observed {
switch req.Method {
case http.MethodGet:
getCount++
case http.MethodPost:
postCount++
if req.SessionID != "" {
postWithSession = true
}
case http.MethodDelete:
deleteCount++
if req.SessionID != "" {
deleteWithSession = true
}
}
if req.Authorization == "Bearer test-token" {
requestsWithAuth++
} else {
requestsWithoutAuth = append(requestsWithoutAuth, req.Method)
}
}
if getCount != 0 {
t.Fatalf("expected no standalone GET requests for %q transport, saw %d", transportType, getCount)
}
if postCount == 0 {
t.Fatal("expected POST requests during streamable HTTP handshake")
}
if deleteCount != 1 {
t.Fatalf("DELETE count = %d, want 1", deleteCount)
}
if !postWithSession {
t.Fatal("expected at least one POST request with Mcp-Session-Id")
}
if !deleteWithSession {
t.Fatal("expected DELETE request with Mcp-Session-Id")
}
if requestsWithAuth != len(observed) {
t.Fatalf("Authorization header missing on requests: %v", requestsWithoutAuth)
}
})
}
}
func TestCallTool_ReconnectsWhenHTTPServerLosesSession(t *testing.T) {
originalConnectServerFunc := connectServerFunc
t.Cleanup(func() {

View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE_COMPOSE="$ROOT_DIR/integration/docker-compose.runner.yml"
SUITES_DIR="$ROOT_DIR/integration/suites"
if [[ ! -f "$BASE_COMPOSE" ]]; then
echo "missing base compose file: $BASE_COMPOSE" >&2
exit 1
fi
if [[ ! -d "$SUITES_DIR" ]]; then
echo "missing integration suites directory: $SUITES_DIR" >&2
exit 1
fi
sanitize_project_name() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-'
}
collect_suite_dirs() {
if [[ "$#" -gt 0 ]]; then
local suite
for suite in "$@"; do
printf '%s\n' "$SUITES_DIR/$suite"
done
return
fi
find "$SUITES_DIR" -mindepth 1 -maxdepth 1 -type d | sort
}
run_suite() {
local suite_dir="$1"
local suite_name
suite_name="$(basename "$suite_dir")"
local manifest="$suite_dir/suite.env"
if [[ ! -f "$manifest" ]]; then
echo "suite $suite_name is missing manifest: $manifest" >&2
return 1
fi
local compose_args=()
compose_args+=(--project-directory "$ROOT_DIR" -p "picoclaw-int-$(sanitize_project_name "$suite_name")")
compose_args+=(-f "$BASE_COMPOSE")
local compose_files=()
while IFS= read -r compose_file; do
compose_files+=("$compose_file")
compose_args+=(-f "$compose_file")
done < <(find "$suite_dir" -maxdepth 1 -type f \( -name 'docker-compose.yml' -o -name 'docker-compose.*.yml' \) | sort)
if [[ "${#compose_files[@]}" -eq 0 ]]; then
echo "suite $suite_name has no docker-compose file" >&2
return 1
fi
(
set -a
# shellcheck disable=SC1090
source "$manifest"
set +a
export INTEGRATION_REPO_ROOT="$ROOT_DIR"
: "${TEST_COMMAND:?suite $suite_name must define TEST_COMMAND in $manifest}"
runner_service="${RUNNER_SERVICE:-integration-runner}"
cleanup() {
docker compose "${compose_args[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> [$suite_name] resolving services"
local services=()
while IFS= read -r service; do
services+=("$service")
done < <(docker compose "${compose_args[@]}" config --services)
local dependency_services=()
for service in "${services[@]}"; do
if [[ "$service" != "$runner_service" ]]; then
dependency_services+=("$service")
fi
done
if [[ "${#dependency_services[@]}" -gt 0 ]]; then
echo "==> [$suite_name] starting docker services: ${dependency_services[*]}"
docker compose "${compose_args[@]}" up -d --build --wait "${dependency_services[@]}"
fi
echo "==> [$suite_name] running: $TEST_COMMAND"
# integration-runner already uses `bash -c` as its entrypoint, so pass the
# suite command as a single argument for Bash to execute directly.
docker compose "${compose_args[@]}" run --rm "$runner_service" "$TEST_COMMAND"
)
}
main() {
local suite_dirs=()
while IFS= read -r suite_dir; do
suite_dirs+=("$suite_dir")
done < <(collect_suite_dirs "$@")
if [[ "${#suite_dirs[@]}" -eq 0 ]]; then
echo "no integration suites found" >&2
exit 1
fi
local suite_dir
for suite_dir in "${suite_dirs[@]}"; do
if [[ ! -d "$suite_dir" ]]; then
echo "unknown integration suite: $suite_dir" >&2
exit 1
fi
run_suite "$suite_dir"
done
}
main "$@"