Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

198 lines
6.8 KiB
Go

package mcp
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/service"
)
// newInMemoryPair connects a real MCP client to srv over an in-memory
// transport, returning both sessions. The caller closes the client and
// waits on the server session.
func newInMemoryPair(
t *testing.T, srv *mcp.Server,
) (*mcp.ServerSession, *mcp.ClientSession) {
t.Helper()
ctx := context.Background()
st, ct := mcp.NewInMemoryTransports()
ss, err := srv.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(
&mcp.Implementation{Name: "test-client", Version: "v0"}, nil)
cs, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
return ss, cs
}
func callParams(name string, args map[string]any) *mcp.CallToolParams {
return &mcp.CallToolParams{Name: name, Arguments: args}
}
func TestNewServer_RegistersSixReadOnlyTools(t *testing.T) {
d := dbtest.OpenTestDB(t)
srv := newServer(ServeOptions{
Service: service.NewDirectBackend(d, nil),
Now: func() time.Time { return fixedNow },
})
require.NotNil(t, srv)
st, ct := newInMemoryPair(t, srv)
tools, err := ct.ListTools(context.Background(), nil)
require.NoError(t, err)
require.Len(t, tools.Tools, 6)
for _, tl := range tools.Tools {
require.NotNil(t, tl.Annotations, "tool %s missing annotations", tl.Name)
require.True(t, tl.Annotations.ReadOnlyHint,
"tool %s should be annotated read-only", tl.Name)
}
require.NoError(t, ct.Close())
require.NoError(t, st.Wait())
}
func TestIsCleanStdioShutdown(t *testing.T) {
t.Parallel()
assert.True(t, isCleanStdioShutdown(nil))
assert.True(t, isCleanStdioShutdown(context.Canceled))
assert.True(t, isCleanStdioShutdown(io.EOF))
assert.True(t, isCleanStdioShutdown(fmt.Errorf("wrap: %w", io.EOF)))
assert.True(t, isCleanStdioShutdown(errors.New("server is closing: EOF")))
assert.True(t, isCleanStdioShutdown(errors.New("connection closed")))
assert.False(t, isCleanStdioShutdown(errors.New("boom")))
assert.False(t, isCleanStdioShutdown(errors.New("open db: permission denied")))
}
type nopWriteCloser struct{ io.Writer }
func (nopWriteCloser) Close() error { return nil }
// TestServeStdio_ClientDisconnectIsClean drives a full session over an
// IOTransport and then closes the input pipe (an abrupt stdin EOF, as
// when a client process exits). Whatever Run returns - nil or the SDK's
// "server is closing" error, which races on timing - must be classified
// as a clean shutdown. This guards against an SDK message change silently
// turning client disconnects into fatal exits.
func TestServeStdio_ClientDisconnectIsClean(t *testing.T) {
d := dbtest.OpenTestDB(t)
msgs := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
`
// Retry to exercise both the nil and the "server is closing" race
// outcomes; both must be recognized as clean.
for range 30 {
srv := newServer(ServeOptions{
Service: service.NewDirectBackend(d, nil),
Now: func() time.Time { return fixedNow },
})
pr, pw := io.Pipe()
tr := &mcp.IOTransport{Reader: pr, Writer: nopWriteCloser{io.Discard}}
done := make(chan error, 1)
go func() { done <- srv.Run(context.Background(), tr) }()
_, _ = io.WriteString(pw, msgs)
require.NoError(t, pw.Close())
select {
case err := <-done:
assert.True(t, isCleanStdioShutdown(err),
"client disconnect must be clean, got %v", err)
case <-time.After(5 * time.Second):
t.Fatal("server did not return after client disconnect")
}
}
}
func TestWithBearerAuth(t *testing.T) {
t.Parallel()
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := func(auth string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", nil)
if auth != "" {
r.Header.Set("Authorization", auth)
}
return r
}
serve := func(h http.Handler, auth string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req(auth))
return rec.Code
}
// Empty token -> no auth wrapper, request passes through.
assert.Equal(t, http.StatusOK, serve(withBearerAuth(ok, ""), ""))
h := withBearerAuth(ok, "s3cret")
assert.Equal(t, http.StatusUnauthorized, serve(h, ""), "missing header")
assert.Equal(t, http.StatusUnauthorized, serve(h, "Bearer wrong"), "wrong token")
assert.Equal(t, http.StatusUnauthorized, serve(h, "s3cret"), "missing Bearer prefix")
assert.Equal(t, http.StatusOK, serve(h, "Bearer s3cret"), "correct token")
}
// TestHTTPHandler_DNSRebindingProtection guards the SDK's built-in
// localhost protection: a request reaching a loopback listener with a
// non-loopback Host header (the DNS-rebinding signature) must be
// rejected. This is regression coverage in case an SDK upgrade flips the
// default or DisableLocalhostProtection is ever set.
func TestHTTPHandler_DNSRebindingProtection(t *testing.T) {
t.Parallel()
d := dbtest.OpenTestDB(t)
ts := httptest.NewServer(newHTTPHandler(ServeOptions{
Service: service.NewDirectBackend(d, nil),
}))
t.Cleanup(ts.Close)
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}`
do := func(host string) int {
req, err := http.NewRequest(http.MethodPost, ts.URL, strings.NewReader(body))
require.NoError(t, err)
if host != "" {
req.Host = host
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
return resp.StatusCode
}
assert.Equal(t, http.StatusForbidden, do("evil.example:1234"),
"spoofed non-loopback Host must be rejected (DNS rebinding)")
assert.NotEqual(t, http.StatusForbidden, do(""),
"legit loopback Host must pass the rebinding check")
}
// TestServeHTTP_ShutsDownOnContextCancel verifies the StreamableHTTP
// serve path tears down gracefully when its context is cancelled,
// returning context.Canceled (which the command treats as a clean exit).
func TestServeHTTP_ShutsDownOnContextCancel(t *testing.T) {
d := dbtest.OpenTestDB(t)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- ServeHTTP(ctx, ServeOptions{
Service: service.NewDirectBackend(d, nil),
}, "127.0.0.1:0")
}()
cancel()
select {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(10 * time.Second):
t.Fatal("ServeHTTP did not return after context cancel")
}
}