Files
pad/internal/mcp/server_test.go
T
2026-09-16 12:00:11 -04:00

251 lines
8.0 KiB
Go

package mcp
import (
"context"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// TestNewServer_Construction is the smoke test: NewServer with a
// zero-value Options must not panic, must return a usable Server, and
// must expose the underlying mcp-go server through MCP() so
// downstream tasks (TASK-945+) can register tools / resources before
// the transport starts.
func TestNewServer_Construction(t *testing.T) {
t.Run("zero options", func(t *testing.T) {
s := NewServer(Options{})
if s == nil {
t.Fatalf("NewServer returned nil")
}
if s.MCP() == nil {
t.Errorf("MCP() returned nil — handlers cannot be registered")
}
})
t.Run("explicit version + debug", func(t *testing.T) {
s := NewServer(Options{Version: "1.2.3", Debug: true})
if s == nil || s.MCP() == nil {
t.Fatalf("NewServer should never return a partially-initialized server")
}
})
}
// TestServer_InitializeHandshake is the contract test: a real
// initialize round-trip through the stdio pipeline must return
// serverInfo with our canonical name + the version we passed in,
// AND must advertise the cmdhelp tool-surface tier at
// capabilities.experimental.padCmdhelp in the initialize result.
//
// This is what every MCP client (Claude Desktop, Cursor, Windsurf,
// mcp-inspector) sees on connect — if it regresses, downstream
// installations break silently.
func TestServer_InitializeHandshake(t *testing.T) {
const wantVersion = "0.42.0-handshake-test"
srv := NewServer(Options{Version: wantVersion})
res, cleanup := runHandshake(t, srv)
defer cleanup()
if res.ServerInfo.Name != ServerName {
t.Errorf("serverInfo.name = %q, want %q", res.ServerInfo.Name, ServerName)
}
if res.ServerInfo.Version != wantVersion {
t.Errorf("serverInfo.version = %q, want %q", res.ServerInfo.Version, wantVersion)
}
// TASK-963: verify the experimental cmdhelp capability is on the
// wire so external agents can pin against the stability tier
// directly from the handshake.
exp := res.Capabilities.Experimental
if exp == nil {
t.Fatalf("capabilities.experimental missing — cmdhelp tier not advertised")
}
assertExperimentalNamespace(t, exp, experimentalCapabilityKey, CmdhelpVersion)
// TASK-979 (PLAN-969): verify the v0.2 tool-surface tier is also on
// the wire. cmdhelp + tool-surface are independent contracts — both
// must be discoverable in the handshake so consumers don't have to
// fetch the meta resource to learn the catalog version.
assertExperimentalNamespace(t, exp, experimentalToolSurfaceKey, ToolSurfaceVersion)
}
// assertExperimentalNamespace fails the test if exp[key] doesn't carry
// the expected version + a tool_surface_stable=true flag. Shared by the
// padCmdhelp and padToolSurface assertions to keep the wire-level
// contract uniform across both namespaces.
func assertExperimentalNamespace(t *testing.T, exp map[string]any, key, wantVersion string) {
t.Helper()
rawNS, ok := exp[key]
if !ok {
t.Fatalf("experimental[%q] missing; got keys %v", key, mapKeys(exp))
}
ns, ok := rawNS.(map[string]any)
if !ok {
t.Fatalf("experimental[%q] is %T, want map[string]any", key, rawNS)
}
if got := ns["version"]; got != wantVersion {
t.Errorf("experimental[%q].version = %v, want %q", key, got, wantVersion)
}
if got := ns["tool_surface_stable"]; got != true {
t.Errorf("experimental[%q].tool_surface_stable = %v, want true", key, got)
}
}
// TestServer_InitializeAdvertisesInstructions locks the wire-level
// invariant that the initialize response carries the server-level
// instructions string TASK-971 introduced. Empty / missing
// instructions would put pad back in the "model has to guess when to
// reach for it" state that triggered PLAN-969.
//
// Asserts the response embeds the constant defined in instructions.go
// rather than a hardcoded literal — keeps the test stable as the
// instructions text evolves.
func TestServer_InitializeAdvertisesInstructions(t *testing.T) {
srv := NewServer(Options{Version: "instr-test"})
res, cleanup := runHandshake(t, srv)
defer cleanup()
if res.Instructions == "" {
t.Fatalf("initialize response has empty instructions; want non-empty")
}
if res.Instructions != Instructions {
t.Errorf("initialize response instructions diverge from embedded source")
}
// Sanity check on the embedded content — confirms instructions.md
// shipped with the build (otherwise the file might be empty after
// a botched embed).
if !strings.Contains(Instructions, "Pad is a project tracker") {
t.Errorf("embedded instructions look truncated or wrong; first 80 chars: %q",
truncate(Instructions, 80))
}
}
// truncate is a small helper for failure messages — keeps long
// instructions text from spilling into terminal output when an
// assertion fires.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// TestServer_FallbackVersion locks the wire-level invariant that
// serverInfo.version is NEVER empty, even when the caller built the
// server with Options{}. Empty values would confuse some clients that
// display them in their UI.
func TestServer_FallbackVersion(t *testing.T) {
srv := NewServer(Options{})
res, cleanup := runHandshake(t, srv)
defer cleanup()
if res.ServerInfo.Version != FallbackVersion {
t.Errorf("serverInfo.version = %q, want fallback %q",
res.ServerInfo.Version, FallbackVersion)
}
}
// TestServer_GracefulShutdownOnContextCancel verifies that cancelling
// the parent context (the path SIGINT / SIGTERM eventually take in
// production) returns RunStdio promptly with a nil error. Guards
// against goroutine leaks on shutdown.
func TestServer_GracefulShutdownOnContextCancel(t *testing.T) {
srv := NewServer(Options{})
stdin, stdinW := io.Pipe()
stdoutR, stdout := io.Pipe()
t.Cleanup(func() {
_ = stdinW.Close()
_ = stdoutR.Close()
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- srv.RunStdio(ctx, stdin, stdout)
}()
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("expected nil on ctx cancel, got: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("RunStdio did not return within 2s of cancel")
}
}
// runHandshake spins up srv against in-memory pipes, drives the MCP
// client's Initialize, and returns the result + a cleanup that tears
// the goroutine down. Shared between the handshake-content tests so
// the wiring stays in one place.
func runHandshake(t *testing.T, srv *Server) (*mcp.InitializeResult, func()) {
t.Helper()
_, res, cleanup := runClientSession(t, srv)
return res, cleanup
}
// runClientSession returns the initialized client as well as the handshake
// result so tests can inspect later protocol responses on the same session.
func runClientSession(t *testing.T, srv *Server) (*client.Client, *mcp.InitializeResult, func()) {
t.Helper()
serverIn, clientOut := io.Pipe()
clientIn, serverOut := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_ = srv.RunStdio(ctx, serverIn, serverOut)
}()
tport := transport.NewIO(clientIn, clientOut, io.NopCloser(strings.NewReader("")))
if err := tport.Start(ctx); err != nil {
cancel()
_ = clientOut.Close()
_ = serverOut.Close()
wg.Wait()
t.Fatalf("transport.Start: %v", err)
}
c := client.NewClient(tport)
var req mcp.InitializeRequest
req.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
req.Params.ClientInfo = mcp.Implementation{
Name: "pad-mcp-test-client",
Version: "test",
}
res, err := c.Initialize(ctx, req)
if err != nil {
cancel()
_ = tport.Close()
_ = clientOut.Close()
_ = serverOut.Close()
wg.Wait()
t.Fatalf("Initialize: %v", err)
}
cleanup := func() {
_ = tport.Close()
cancel()
// Closing the writer halves unblocks the server's reader so
// Listen returns and the goroutine exits.
_ = clientOut.Close()
_ = serverOut.Close()
wg.Wait()
}
return c, res, cleanup
}