mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
Relays that re-serialize tool schemas into typed structs turn an omitted "required" into null, and strict upstreams then reject the whole request: "Invalid schema for function 'get_goal': null is not of type 'array'". get_goal's schema has no required list, and neither do many MCP tools. CanonicalizeSchema now adds "required": [] to every root object schema that lacks one (including the no-argument fallback), so builtin and MCP tools reach the provider with an explicit array. The effect test checks every tool in the real provider request. Cache impact: the tool schema bytes change once (get_goal gains "required": []), so ToolsHash and PrefixHash move in both golden baselines and the first request after upgrade misses the prefix cache. Goldens regenerated with REASONIX_UPDATE_GOLDEN=1.
1705 lines
57 KiB
Go
1705 lines
57 KiB
Go
package plugin
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"reasonix/internal/event"
|
||
"reasonix/internal/mcplaunch"
|
||
"reasonix/internal/sandbox"
|
||
"reasonix/internal/tool"
|
||
)
|
||
|
||
type countingToolsTransport struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
raw json.RawMessage
|
||
}
|
||
|
||
func (t *countingToolsTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||
if method != "tools/list" {
|
||
return json.RawMessage(`{}`), nil
|
||
}
|
||
t.mu.Lock()
|
||
t.calls++
|
||
t.mu.Unlock()
|
||
if len(t.raw) > 0 {
|
||
return t.raw, nil
|
||
}
|
||
return json.RawMessage(`{"tools":[{"name":"zed","description":"Sorted after echo.","inputSchema":{"type":"object"}},{"name":"echo","description":"Echo back the message.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["z","msg"]},"annotations":{"readOnlyHint":true}}]}`), nil
|
||
}
|
||
|
||
func (t *countingToolsTransport) close() {}
|
||
|
||
func (t *countingToolsTransport) toolsListCalls() int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return t.calls
|
||
}
|
||
|
||
type sequenceToolsTransport struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
raws []json.RawMessage
|
||
}
|
||
|
||
func (t *sequenceToolsTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||
if method != "tools/list" {
|
||
return json.RawMessage(`{}`), nil
|
||
}
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.calls++
|
||
if len(t.raws) == 0 {
|
||
return json.RawMessage(`{"tools":[]}`), nil
|
||
}
|
||
idx := t.calls - 1
|
||
if idx >= len(t.raws) {
|
||
idx = len(t.raws) - 1
|
||
}
|
||
return t.raws[idx], nil
|
||
}
|
||
|
||
func (t *sequenceToolsTransport) close() {}
|
||
|
||
func (t *sequenceToolsTransport) toolsListCalls() int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return t.calls
|
||
}
|
||
|
||
type deadlineRecordingTransport struct {
|
||
mu sync.Mutex
|
||
deadline []time.Duration
|
||
methods []string
|
||
block bool
|
||
noContext bool
|
||
}
|
||
|
||
func (t *deadlineRecordingTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||
if d, ok := ctx.Deadline(); ok {
|
||
t.mu.Lock()
|
||
t.deadline = append(t.deadline, time.Until(d))
|
||
t.methods = append(t.methods, method)
|
||
t.mu.Unlock()
|
||
} else {
|
||
t.mu.Lock()
|
||
t.noContext = true
|
||
t.methods = append(t.methods, method)
|
||
t.mu.Unlock()
|
||
}
|
||
if t.block {
|
||
<-ctx.Done()
|
||
return nil, ctx.Err()
|
||
}
|
||
return json.RawMessage(`{}`), nil
|
||
}
|
||
|
||
func (t *deadlineRecordingTransport) close() {}
|
||
|
||
func (t *deadlineRecordingTransport) lastDeadline(tst *testing.T) time.Duration {
|
||
tst.Helper()
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if len(t.deadline) == 0 {
|
||
tst.Fatalf("transport recorded no deadline; methods=%v noContext=%v", t.methods, t.noContext)
|
||
}
|
||
return t.deadline[len(t.deadline)-1]
|
||
}
|
||
|
||
func assertDeadlineNear(t *testing.T, got, want time.Duration) {
|
||
t.Helper()
|
||
if got < want-2*time.Second || got > want+2*time.Second {
|
||
t.Fatalf("deadline = %v, want near %v", got, want)
|
||
}
|
||
}
|
||
|
||
func TestMCPRuntimeSpecMatchesExactHostIdentity(t *testing.T) {
|
||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||
managerA := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), workspace)
|
||
managerB := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), workspace)
|
||
base := Spec{
|
||
Name: "database", Package: "trusted-package", Type: "http",
|
||
Command: "launcher", Args: []string{"--serve"}, Env: map[string]string{"TOKEN": "secret-a"},
|
||
URL: "https://example.invalid/mcp", Headers: map[string]string{"Authorization": "Bearer secret-a"},
|
||
DefaultStartupTimeout: 30 * time.Second, StartupTimeout: 45 * time.Second,
|
||
DefaultCallTimeout: 5 * time.Minute, CallTimeout: 30 * time.Second,
|
||
ToolTimeouts: map[string]time.Duration{"query": 45 * time.Second},
|
||
Dir: "/work", WorkspaceRoot: workspace, LaunchManager: managerA,
|
||
ConfigSource: "project_config", Authorized: true, RequireLaunchApproval: true,
|
||
LaunchArgs: []string{"[email protected]", "--offline"}, LauncherIdentityArgs: []string{"[email protected]"},
|
||
LauncherLocator: "[email protected]", LauncherResolvedVersion: "1.0.0", LauncherDigest: "digest-a",
|
||
ProcessMode: MCPProcessConfined,
|
||
Sandbox: sandbox.Spec{
|
||
Mode: "enforce", WriteRoots: []string{"/write"}, ForbidReadRoots: []string{"/secret"},
|
||
Network: true, MinimalWrites: true, Shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/bash"},
|
||
},
|
||
StateDir: "/state", StripRawPrefix: "db_", LowPriority: true,
|
||
}
|
||
|
||
equivalent := base
|
||
equivalent.Type = "streamable_http"
|
||
equivalent.LaunchManager = managerB
|
||
equivalent.Authorized = false // Authorization is checked separately from runtime identity.
|
||
equivalent.Stderr = &bytes.Buffer{}
|
||
if !MCPRuntimeSpecMatches(base, equivalent) {
|
||
t.Fatal("equivalent runtime specs with separate authorization/stderr handles did not match")
|
||
}
|
||
|
||
emptyA := Spec{Name: "empty", Type: "", Args: nil, Env: nil, Headers: nil, ToolTimeouts: nil}
|
||
emptyB := Spec{Name: "empty", Type: "stdio", Args: []string{}, Env: map[string]string{}, Headers: map[string]string{}, ToolTimeouts: map[string]time.Duration{}}
|
||
if !MCPRuntimeSpecMatches(emptyA, emptyB) {
|
||
t.Fatal("nil and empty runtime collections should be behaviorally equivalent")
|
||
}
|
||
|
||
mutations := []struct {
|
||
name string
|
||
mutate func(*Spec)
|
||
}{
|
||
{name: "endpoint", mutate: func(s *Spec) { s.URL = "https://other.invalid/mcp" }},
|
||
{name: "header secret", mutate: func(s *Spec) { s.Headers = map[string]string{"Authorization": "Bearer secret-b"} }},
|
||
{name: "environment secret", mutate: func(s *Spec) { s.Env = map[string]string{"TOKEN": "secret-b"} }},
|
||
{name: "default startup timeout", mutate: func(s *Spec) { s.DefaultStartupTimeout = time.Minute }},
|
||
{name: "startup timeout", mutate: func(s *Spec) { s.StartupTimeout = time.Minute }},
|
||
{name: "config source", mutate: func(s *Spec) { s.ConfigSource = "user_config" }},
|
||
{name: "workspace", mutate: func(s *Spec) { s.WorkspaceRoot = "/other-workspace" }},
|
||
{name: "launcher digest", mutate: func(s *Spec) { s.LauncherDigest = "digest-b" }},
|
||
{name: "sandbox", mutate: func(s *Spec) { s.Sandbox.Network = false }},
|
||
{name: "prefix", mutate: func(s *Spec) { s.StripRawPrefix = "other_" }},
|
||
}
|
||
for _, tc := range mutations {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
changed := base
|
||
tc.mutate(&changed)
|
||
if MCPRuntimeSpecMatches(base, changed) {
|
||
t.Fatalf("runtime identity ignored %s change", tc.name)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestClientCallAppliesBuiltInDefaultTimeout(t *testing.T) {
|
||
for _, transportName := range []string{"stdio", "http"} {
|
||
t.Run(transportName, func(t *testing.T) {
|
||
tr := &deadlineRecordingTransport{}
|
||
c := &Client{name: "maker", t: tr, spec: Spec{Name: "maker"}, transport: transportName}
|
||
if _, err := c.call(context.Background(), "tools/list", map[string]any{}); err != nil {
|
||
t.Fatalf("call: %v", err)
|
||
}
|
||
assertDeadlineNear(t, tr.lastDeadline(t), defaultCallTimeout)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestClientCallTimeoutPrecedence(t *testing.T) {
|
||
tr := &deadlineRecordingTransport{}
|
||
c := &Client{
|
||
name: "maker",
|
||
t: tr,
|
||
spec: Spec{
|
||
Name: "maker",
|
||
DefaultCallTimeout: 300 * time.Second,
|
||
CallTimeout: 600 * time.Second,
|
||
ToolTimeouts: map[string]time.Duration{"generate_video": 1800 * time.Second},
|
||
},
|
||
transport: "stdio",
|
||
}
|
||
|
||
if _, err := c.call(context.Background(), "tools/call", map[string]any{"name": "generate_video"}); err != nil {
|
||
t.Fatalf("tool override call: %v", err)
|
||
}
|
||
assertDeadlineNear(t, tr.lastDeadline(t), 1800*time.Second)
|
||
|
||
if _, err := c.call(context.Background(), "tools/call", map[string]any{"name": "search"}); err != nil {
|
||
t.Fatalf("plugin override call: %v", err)
|
||
}
|
||
assertDeadlineNear(t, tr.lastDeadline(t), 600*time.Second)
|
||
|
||
if _, err := c.call(context.Background(), "prompts/list", map[string]any{}); err != nil {
|
||
t.Fatalf("method call: %v", err)
|
||
}
|
||
assertDeadlineNear(t, tr.lastDeadline(t), 600*time.Second)
|
||
}
|
||
|
||
func TestClientCallRespectsParentDeadline(t *testing.T) {
|
||
tr := &deadlineRecordingTransport{}
|
||
c := &Client{
|
||
name: "maker",
|
||
t: tr,
|
||
spec: Spec{
|
||
Name: "maker",
|
||
CallTimeout: 10 * time.Minute,
|
||
},
|
||
transport: "http",
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||
defer cancel()
|
||
if _, err := c.call(ctx, "tools/call", map[string]any{"name": "generate_video"}); err != nil {
|
||
t.Fatalf("call: %v", err)
|
||
}
|
||
got := tr.lastDeadline(t)
|
||
if got > 150*time.Millisecond {
|
||
t.Fatalf("deadline = %v, want caller deadline around 100ms", got)
|
||
}
|
||
}
|
||
|
||
func TestClientCallTimeoutErrorNamesToolAndConfig(t *testing.T) {
|
||
tr := &deadlineRecordingTransport{block: true}
|
||
c := &Client{
|
||
name: "maker",
|
||
t: tr,
|
||
spec: Spec{
|
||
Name: "maker",
|
||
CallTimeout: 25 * time.Millisecond,
|
||
},
|
||
transport: "stdio",
|
||
}
|
||
_, err := c.call(context.Background(), "tools/call", map[string]any{"name": "generate_video"})
|
||
if err == nil {
|
||
t.Fatal("timed-out call returned nil error")
|
||
}
|
||
if !errors.Is(err, context.DeadlineExceeded) {
|
||
t.Fatalf("error should wrap context deadline exceeded, got %v", err)
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, `MCP tool "maker.generate_video" timed out after 25ms`) ||
|
||
!strings.Contains(msg, "tool_timeout_seconds or call_timeout_seconds") {
|
||
t.Fatalf("timeout error lacks useful guidance: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestStdioEndToEnd drives a real subprocess (this test binary re-invoked in
|
||
// helper mode) through the full MCP handshake and a tool call, exercising
|
||
// StartAll, tools/list, and tools/call over stdio JSON-RPC.
|
||
func TestStdioEndToEnd(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
spec := Spec{
|
||
Name: "mock",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||
}
|
||
|
||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||
if err != nil {
|
||
t.Fatalf("StartAll: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
if len(tools) != 2 {
|
||
t.Fatalf("want 2 tools, got %d", len(tools))
|
||
}
|
||
if got := tools[0].Name(); got != "mcp__mock__echo" {
|
||
t.Fatalf("tool name: want mcp__mock__echo, got %q", got)
|
||
}
|
||
if got, want := string(tools[0].Schema()), `{"properties":{"msg":{"type":"string"}},"required":["msg","z"],"type":"object"}`; got != want {
|
||
t.Fatalf("tool schema = %s, want %s", got, want)
|
||
}
|
||
|
||
out, err := tools[0].Execute(ctx, json.RawMessage(`{"msg":"hi"}`))
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if out != "echo: hi" {
|
||
t.Fatalf("result: want %q, got %q", "echo: hi", out)
|
||
}
|
||
}
|
||
|
||
func TestHostToolsForReusesCachedTools(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &countingToolsTransport{}
|
||
host := NewHost()
|
||
defer host.Close()
|
||
host.clients = []*Client{{
|
||
name: "mock",
|
||
t: tr,
|
||
spec: Spec{Name: "mock"},
|
||
transport: "stdio",
|
||
}}
|
||
|
||
first, err := host.ToolsFor(ctx, "mock")
|
||
if err != nil {
|
||
t.Fatalf("first ToolsFor: %v", err)
|
||
}
|
||
second, err := host.ToolsFor(ctx, "mock")
|
||
if err != nil {
|
||
t.Fatalf("second ToolsFor: %v", err)
|
||
}
|
||
if got := tr.toolsListCalls(); got != 1 {
|
||
t.Fatalf("tools/list calls = %d, want 1", got)
|
||
}
|
||
if len(first) != 2 || len(second) != 2 {
|
||
t.Fatalf("ToolsFor lengths = %d and %d, want 2 each", len(first), len(second))
|
||
}
|
||
if got := first[0].Name(); got != "mcp__mock__echo" {
|
||
t.Fatalf("first tool name = %q, want sorted echo first", got)
|
||
}
|
||
if got, want := string(second[0].Schema()), string(first[0].Schema()); got != want {
|
||
t.Fatalf("cached schema changed:\n first=%s\nsecond=%s", want, got)
|
||
}
|
||
if !second[0].ReadOnly() {
|
||
t.Fatal("cached tool lost readOnlyHint")
|
||
}
|
||
|
||
statuses := host.Servers()
|
||
if len(statuses) != 1 || len(statuses[0].ToolList) != 2 {
|
||
t.Fatalf("server tool status = %+v, want cached tool metadata", statuses)
|
||
}
|
||
if statuses[0].ToolList[0].Name != "echo" || !statuses[0].ToolList[0].ReadOnlyHint {
|
||
t.Fatalf("tool metadata = %+v, want sorted echo with readOnlyHint", statuses[0].ToolList)
|
||
}
|
||
}
|
||
|
||
func TestHostToolsForCachesEmptyToolList(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &countingToolsTransport{raw: json.RawMessage(`{"tools":[]}`)}
|
||
host := NewHost()
|
||
defer host.Close()
|
||
host.clients = []*Client{{
|
||
name: "empty",
|
||
t: tr,
|
||
spec: Spec{Name: "empty"},
|
||
transport: "stdio",
|
||
}}
|
||
|
||
first, err := host.ToolsFor(ctx, "empty")
|
||
if err != nil {
|
||
t.Fatalf("first ToolsFor: %v", err)
|
||
}
|
||
second, err := host.ToolsFor(ctx, "empty")
|
||
if err != nil {
|
||
t.Fatalf("second ToolsFor: %v", err)
|
||
}
|
||
if len(first) != 0 || len(second) != 0 {
|
||
t.Fatalf("ToolsFor lengths = %d and %d, want 0 each", len(first), len(second))
|
||
}
|
||
if got := tr.toolsListCalls(); got != 1 {
|
||
t.Fatalf("empty tools/list calls = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
func TestClientListToolsRetriesAdvertisedEmptyToolList(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &sequenceToolsTransport{raws: []json.RawMessage{
|
||
json.RawMessage(`{"tools":[]}`),
|
||
json.RawMessage(`{"tools":[{"name":"echo","description":"Echo back the message.","inputSchema":{"type":"object"}}]}`),
|
||
}}
|
||
c := &Client{
|
||
name: "race",
|
||
t: tr,
|
||
spec: Spec{Name: "race"},
|
||
transport: "stdio",
|
||
capabilities: clientCapabilities{tools: true},
|
||
}
|
||
|
||
tools, err := c.listTools(ctx)
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
if len(tools) != 1 || tools[0].Name() != "mcp__race__echo" {
|
||
t.Fatalf("tools = %v, want mcp__race__echo", names(tools))
|
||
}
|
||
if got := tr.toolsListCalls(); got != 2 {
|
||
t.Fatalf("tools/list calls = %d, want 2", got)
|
||
}
|
||
}
|
||
|
||
func TestClientListToolsQuarantinesMalformedSchema(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &countingToolsTransport{raw: json.RawMessage(`{
|
||
"tools":[
|
||
{"name":"echo","description":"Still available.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}}}},
|
||
{"name":"generate_yso_bytes","description":"Broken nested schema.","inputSchema":{"type":"object","properties":{"options":{"type":"array","items":{"key":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}}}}}}
|
||
]
|
||
}`)}
|
||
c := &Client{name: "yakit", t: tr, spec: Spec{Name: "yakit"}, transport: "stdio"}
|
||
|
||
tools, err := c.listTools(ctx)
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
if len(tools) != 1 || tools[0].Name() != "mcp__yakit__echo" {
|
||
t.Fatalf("tools = %v, want only mcp__yakit__echo", names(tools))
|
||
}
|
||
if got := string(tools[0].Schema()); got != `{"properties":{"msg":{"type":"string"}},"required":[],"type":"object"}` {
|
||
t.Fatalf("valid sibling schema changed: %s", got)
|
||
}
|
||
if len(c.toolCatalog.infos) != 2 {
|
||
t.Fatalf("tool status count = %d, want both advertised tools", len(c.toolCatalog.infos))
|
||
}
|
||
if c.toolCatalog.infos[0].Name != "echo" || c.toolCatalog.infos[0].SchemaError != "" {
|
||
t.Fatalf("valid tool status = %+v", c.toolCatalog.infos[0])
|
||
}
|
||
if c.toolCatalog.infos[1].Name != "generate_yso_bytes" || !strings.Contains(c.toolCatalog.infos[1].SchemaError, "/properties/options/items/type") {
|
||
t.Fatalf("quarantined tool status = %+v", c.toolCatalog.infos[1])
|
||
}
|
||
}
|
||
|
||
func TestClientListToolsQuarantinesNonObjectRootSchemas(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &countingToolsTransport{raw: json.RawMessage(`{
|
||
"tools":[
|
||
{"name":"echo","description":"Still available.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}}}},
|
||
{"name":"no_args","description":"Bare empty schema.","inputSchema":{}},
|
||
{"name":"nullable_root","description":"Union root type.","inputSchema":{"type":["object","null"]}},
|
||
{"name":"string_root","description":"Non-object root type.","inputSchema":{"type":"string"}}
|
||
]
|
||
}`)}
|
||
c := &Client{name: "srv", t: tr, spec: Spec{Name: "srv"}, transport: "stdio"}
|
||
|
||
tools, err := c.listTools(ctx)
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
if len(tools) != 2 || tools[0].Name() != "mcp__srv__echo" || tools[1].Name() != "mcp__srv__no_args" {
|
||
t.Fatalf("tools = %v, want echo and normalized no_args", names(tools))
|
||
}
|
||
if got := string(tools[1].Schema()); got != `{"properties":{},"required":[],"type":"object"}` {
|
||
t.Fatalf("no_args schema = %s, want normalized empty object schema", got)
|
||
}
|
||
if len(c.toolCatalog.infos) != 4 {
|
||
t.Fatalf("tool status count = %d, want all advertised tools", len(c.toolCatalog.infos))
|
||
}
|
||
for _, info := range c.toolCatalog.infos {
|
||
switch info.Name {
|
||
case "echo", "no_args":
|
||
if info.SchemaError != "" {
|
||
t.Fatalf("usable tool status = %+v", info)
|
||
}
|
||
case "nullable_root", "string_root":
|
||
if !strings.Contains(info.SchemaError, `"object"`) {
|
||
t.Fatalf("quarantined tool status = %+v", info)
|
||
}
|
||
default:
|
||
t.Fatalf("unexpected tool status %+v", info)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestClientListToolsValidatesAfterCompatibilityNormalization(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
tr := &countingToolsTransport{raw: json.RawMessage(`{"tools":[{"name":"legacy","inputSchema":{"type":"object","properties":{"query":{"type":"string","required":true}}}}]}`)}
|
||
c := &Client{name: "legacy", t: tr, spec: Spec{Name: "legacy"}, transport: "stdio"}
|
||
|
||
tools, err := c.listTools(ctx)
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
if len(tools) != 1 {
|
||
t.Fatalf("tools = %v, want normalized legacy tool", names(tools))
|
||
}
|
||
if got := string(tools[0].Schema()); got != `{"properties":{"query":{"type":"string"}},"required":[],"type":"object"}` {
|
||
t.Fatalf("normalized schema = %s", got)
|
||
}
|
||
}
|
||
|
||
func TestClientListToolsPropagatesReadOnlyAndDestructiveHints(t *testing.T) {
|
||
tr := &countingToolsTransport{raw: json.RawMessage(`{
|
||
"tools":[{
|
||
"name":"wipe",
|
||
"description":"Delete generated state.",
|
||
"inputSchema":{"type":"object"},
|
||
"annotations":{"readOnlyHint":true,"destructiveHint":true}
|
||
}]
|
||
}`)}
|
||
c := &Client{name: "srv", t: tr, spec: Spec{Name: "srv"}, transport: "stdio"}
|
||
|
||
tools, err := c.listTools(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
if len(tools) != 1 || !tools[0].ReadOnly() {
|
||
t.Fatalf("tools = %v, want one read-only tool", names(tools))
|
||
}
|
||
annotations, ok := tools[0].(tool.MCPAnnotations)
|
||
if !ok || !annotations.MCPDestructiveHint() {
|
||
t.Fatalf("tool annotations = (%T, %v), want destructive hint", tools[0], ok)
|
||
}
|
||
if len(c.toolCatalog.infos) != 1 || !c.toolCatalog.infos[0].ReadOnlyHint || !c.toolCatalog.infos[0].DestructiveHint {
|
||
t.Fatalf("tool status = %+v, want both MCP hints", c.toolCatalog.infos)
|
||
}
|
||
}
|
||
|
||
func TestUserAuthorizedMCPHintedReaderIsAuthorizedForSubagents(t *testing.T) {
|
||
client := &Client{
|
||
name: "mock", t: &countingToolsTransport{},
|
||
spec: Spec{Name: "mock", Authorized: true},
|
||
}
|
||
tools, err := client.listTools(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("listTools: %v", err)
|
||
}
|
||
echo := findToolByName(tools, "mcp__mock__echo")
|
||
if echo == nil || !echo.ReadOnly() {
|
||
t.Fatalf("installed hinted reader missing or not read-only: %T", echo)
|
||
}
|
||
if authority, ok := echo.(tool.MCPServerAuthorization); !ok || !authority.MCPServerAuthorized() {
|
||
t.Fatalf("installed hinted reader lacks server authorization: %T", echo)
|
||
}
|
||
if _, err := echo.Execute(tool.WithReaderExecutionIntent(context.Background()), json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
|
||
t.Fatalf("installed hinted reader dispatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestServerAuthorizedUsesResolvedBooleanOnly(t *testing.T) {
|
||
if !(Spec{Authorized: true}).ServerAuthorized() {
|
||
t.Fatal("an explicitly authorized server should not require a launch manager")
|
||
}
|
||
if (Spec{}).ServerAuthorized() {
|
||
t.Fatal("an unresolved server should remain unauthorized")
|
||
}
|
||
}
|
||
|
||
func TestInstalledServerAuthorizationSkipsProjectIdentityDigest(t *testing.T) {
|
||
installed := Spec{Name: "installed", Authorized: true}
|
||
resolved, err := resolveProjectLaunchAuthorization(context.Background(), installed)
|
||
if err != nil || !resolved.ServerAuthorized() {
|
||
t.Fatalf("installed authorization = (%+v, %v), want authorized without identity resolution", resolved, err)
|
||
}
|
||
|
||
project := Spec{
|
||
Name: "project", RequireLaunchApproval: true,
|
||
LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
|
||
}
|
||
if _, err := resolveProjectLaunchAuthorization(context.Background(), project); err == nil || !strings.Contains(err.Error(), "command is required") {
|
||
t.Fatalf("project authorization did not resolve its exact launch identity: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestApplyKnownOverridesPinsCodeGraphStdioToWorkspace(t *testing.T) {
|
||
got := ApplyKnownOverrides(Spec{Name: "codegraph"}, "/workspace")
|
||
if got.Dir != "/workspace" {
|
||
t.Fatalf("codegraph stdio Dir = %q, want workspace root", got.Dir)
|
||
}
|
||
if got.Env[codeGraphDaemonIdleTimeoutEnv] != codeGraphDaemonIdleTimeoutDefaultMS {
|
||
t.Fatalf("codegraph daemon idle timeout env = %q, want %s; env=%v", got.Env[codeGraphDaemonIdleTimeoutEnv], codeGraphDaemonIdleTimeoutDefaultMS, got.Env)
|
||
}
|
||
|
||
preset := ApplyKnownOverrides(Spec{Name: "codegraph", Dir: "/custom"}, "/workspace")
|
||
if preset.Dir != "/custom" {
|
||
t.Fatalf("existing Dir should be preserved, got %q", preset.Dir)
|
||
}
|
||
|
||
httpSpec := ApplyKnownOverrides(Spec{Name: "codegraph", Type: "http"}, "/workspace")
|
||
if httpSpec.Dir != "" {
|
||
t.Fatalf("http codegraph should not receive stdio Dir, got %q", httpSpec.Dir)
|
||
}
|
||
if _, ok := httpSpec.Env[codeGraphDaemonIdleTimeoutEnv]; ok {
|
||
t.Fatalf("http codegraph should not receive daemon idle env, got %+v", httpSpec.Env)
|
||
}
|
||
|
||
other := ApplyKnownOverrides(Spec{Name: "other"}, "/workspace")
|
||
if other.Dir != "" {
|
||
t.Fatalf("non-codegraph should not receive Dir, got %q", other.Dir)
|
||
}
|
||
if _, ok := other.Env[codeGraphDaemonIdleTimeoutEnv]; ok {
|
||
t.Fatalf("non-codegraph should not receive daemon idle env, got %+v", other.Env)
|
||
}
|
||
}
|
||
|
||
func TestApplyKnownOverridesPinsCodebaseMemoryToWorkspace(t *testing.T) {
|
||
got := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp"}, "/workspace")
|
||
if got.Dir != "/workspace" {
|
||
t.Fatalf("codebase-memory-mcp stdio Dir = %q, want workspace root", got.Dir)
|
||
}
|
||
if !got.LowPriority {
|
||
t.Fatalf("codebase-memory-mcp should run at low priority")
|
||
}
|
||
|
||
preset := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp", Dir: "/custom"}, "/workspace")
|
||
if preset.Dir != "/custom" {
|
||
t.Fatalf("existing Dir should be preserved, got %q", preset.Dir)
|
||
}
|
||
|
||
httpSpec := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp", Type: "http"}, "/workspace")
|
||
if httpSpec.Dir != "" {
|
||
t.Fatalf("http codebase-memory-mcp should not receive stdio Dir, got %q", httpSpec.Dir)
|
||
}
|
||
|
||
npxSpec := ApplyKnownOverrides(Spec{
|
||
Name: "custom",
|
||
Command: "npx",
|
||
Args: []string{"-y", "codebase-memory-mcp@latest"},
|
||
}, "/workspace")
|
||
if npxSpec.Dir != "/workspace" || !npxSpec.LowPriority {
|
||
t.Fatalf("npx codebase-memory-mcp override missing: %+v", npxSpec)
|
||
}
|
||
}
|
||
|
||
func TestApplyKnownOverridesPreservesConfiguredCodeGraphDaemonIdleTimeout(t *testing.T) {
|
||
got := ApplyKnownOverrides(Spec{
|
||
Name: "codegraph",
|
||
Env: map[string]string{codeGraphDaemonIdleTimeoutEnv: "30000"},
|
||
}, "/workspace")
|
||
|
||
if got.Env[codeGraphDaemonIdleTimeoutEnv] != "30000" {
|
||
t.Fatalf("configured codegraph daemon idle timeout was overwritten: %+v", got.Env)
|
||
}
|
||
}
|
||
|
||
func TestStartAvailableKeepsGoodServers(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
good := Spec{
|
||
Name: "good",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||
}
|
||
bad := Spec{Name: "bad", Command: "reasonix-missing-mcp-binary"}
|
||
|
||
host, tools := StartAvailable(ctx, []Spec{bad, good})
|
||
defer host.Close()
|
||
|
||
if len(tools) != 2 {
|
||
t.Fatalf("want tools from the good server, got %d", len(tools))
|
||
}
|
||
if got := host.ServerNames(); len(got) != 1 || got[0] != "good" {
|
||
t.Fatalf("connected servers = %v, want [good]", got)
|
||
}
|
||
failures := host.Failures()
|
||
if len(failures) != 1 || failures[0].Name != "bad" {
|
||
t.Fatalf("failures = %+v, want bad", failures)
|
||
}
|
||
}
|
||
|
||
func TestRecordFailurePreservesLaunchApprovalAction(t *testing.T) {
|
||
host := NewHost()
|
||
host.RecordFailure(Spec{Name: "project", Type: "stdio"}, fmt.Errorf("connect project MCP: %w", &launchApprovalError{server: "project"}))
|
||
host.RecordFailure(Spec{Name: "ordinary", Type: "stdio"}, errors.New("connection refused"))
|
||
|
||
failures := host.Failures()
|
||
if len(failures) != 2 {
|
||
t.Fatalf("failures = %+v, want two", failures)
|
||
}
|
||
if !failures[0].RequiresLaunchApproval {
|
||
t.Fatalf("project launch failure = %+v, want authorization action", failures[0])
|
||
}
|
||
if failures[1].RequiresLaunchApproval {
|
||
t.Fatalf("ordinary failure = %+v, must remain retryable", failures[1])
|
||
}
|
||
}
|
||
|
||
// TestStartAllAllOrNothingOnFailure pins the strict StartAll contract the
|
||
// parallel rewrite must preserve: any single plugin failing aborts the whole
|
||
// set, returns no Host or tools, and tears down every server that did start —
|
||
// including, under parallel start, a good server whose index sits after the
|
||
// failing one ([bad, good]). On error the Host is nil, so callers never see a
|
||
// half-built set; the started servers are closed before StartAll returns.
|
||
func TestStartAllAllOrNothingOnFailure(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
good := Spec{
|
||
Name: "good",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||
}
|
||
bad := Spec{Name: "bad", Command: "reasonix-missing-mcp-binary"}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
specs []Spec
|
||
}{
|
||
{"failure first", []Spec{bad, good}},
|
||
{"failure last", []Spec{good, bad}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
host, tools, err := StartAll(ctx, tc.specs)
|
||
if err == nil {
|
||
if host != nil {
|
||
host.Close()
|
||
}
|
||
t.Fatal("StartAll should fail when a plugin can't start")
|
||
}
|
||
if host != nil || tools != nil {
|
||
t.Fatalf("failed StartAll must return nil host/tools, got host=%v tools=%d", host, len(tools))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestStdioFailureCapturesStderr(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
host, _ := StartAvailable(ctx, []Spec{{
|
||
Name: "stderr",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_STDERR_EXIT": "1"},
|
||
}})
|
||
defer host.Close()
|
||
|
||
failures := host.Failures()
|
||
if len(failures) != 1 {
|
||
t.Fatalf("failures = %+v, want one", failures)
|
||
}
|
||
if !strings.Contains(failures[0].Error, "helper stderr boom") {
|
||
t.Fatalf("failure should include stderr, got %q", failures[0].Error)
|
||
}
|
||
}
|
||
|
||
func TestStartupFailureReportsStageElapsedAndRedactedStderr(t *testing.T) {
|
||
lifeCtx := t.Context()
|
||
startupCtx, cancelStartup := context.WithTimeout(lifeCtx, 40*time.Millisecond)
|
||
defer cancelStartup()
|
||
|
||
host := NewHost()
|
||
defer host.Close()
|
||
spec := Spec{
|
||
Name: "slow-stderr",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "250",
|
||
"GO_WANT_HELPER_STARTUP_STDERR": "Authorization: Bearer startup-secret-value",
|
||
},
|
||
}
|
||
_, err := host.AddWithLifecycle(lifeCtx, startupCtx, spec)
|
||
if err == nil {
|
||
t.Fatal("slow initialize unexpectedly succeeded")
|
||
}
|
||
msg := err.Error()
|
||
for _, want := range []string{"initialize", "after "} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Fatalf("startup error missing %q: %v", want, err)
|
||
}
|
||
}
|
||
if strings.Contains(msg, "startup-secret-value") {
|
||
t.Fatalf("startup error leaked credential: %v", err)
|
||
}
|
||
|
||
host.RecordFailure(spec, err)
|
||
failures := host.Failures()
|
||
if len(failures) != 1 || failures[0].Stage != "initialize" || failures[0].Elapsed <= 0 {
|
||
t.Fatalf("structured startup failure = %+v", failures)
|
||
}
|
||
if stderr := failures[0].Stderr; stderr != "" && !strings.Contains(stderr, "Bearer [redacted]") {
|
||
t.Fatalf("structured stderr was not redacted: %+v", failures[0])
|
||
}
|
||
}
|
||
|
||
func TestFailureSummaryRedactsCredentials(t *testing.T) {
|
||
got := summarizeFailureError(errors.New("startup failed: Authorization: Bearer summary-secret-value"))
|
||
if strings.Contains(got, "summary-secret-value") || !strings.Contains(got, "Bearer [redacted]") {
|
||
t.Fatalf("failure summary was not redacted: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestEnsureConnectedInBackgroundSurvivesShortCallerWait(t *testing.T) {
|
||
lifeCtx := t.Context()
|
||
host := NewHost()
|
||
defer host.Close()
|
||
spec := Spec{
|
||
Name: "slow-background",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
StartupTimeout: 2 * time.Second,
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "150",
|
||
},
|
||
}
|
||
result := host.EnsureConnectedInBackground(lifeCtx, spec)
|
||
select {
|
||
case got := <-result:
|
||
t.Fatalf("background startup settled before the short caller wait: %+v", got)
|
||
case <-time.After(20 * time.Millisecond):
|
||
// The caller can return here without cancelling the session-owned startup.
|
||
}
|
||
select {
|
||
case got := <-result:
|
||
if got.Err != nil || len(got.Tools) != 2 {
|
||
t.Fatalf("background startup result = %+v", got)
|
||
}
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("background startup did not finish")
|
||
}
|
||
if !host.HasClient(spec.Name) {
|
||
t.Fatal("successful background startup did not leave a session-owned client")
|
||
}
|
||
}
|
||
|
||
func TestEnsureConnectedInBackgroundRemoveDoesNotResurrectServer(t *testing.T) {
|
||
lifeCtx := t.Context()
|
||
host := NewHost()
|
||
defer host.Close()
|
||
spec := Spec{
|
||
Name: "removed-background",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
StartupTimeout: 2 * time.Second,
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "500",
|
||
},
|
||
}
|
||
result := host.EnsureConnectedInBackground(lifeCtx, spec)
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for {
|
||
connecting := host.ConnectingServers()
|
||
if len(connecting) > 0 {
|
||
if len(connecting) != 1 || connecting[0] != spec.Name {
|
||
t.Fatalf("ConnectingServers = %v, want exact configured name %q", connecting, spec.Name)
|
||
}
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatal("background startup never entered the in-flight state")
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
if _, found := host.Remove(spec.Name); !found {
|
||
t.Fatal("Host.Remove did not cancel the background generation")
|
||
}
|
||
select {
|
||
case got := <-result:
|
||
if got.Err == nil {
|
||
t.Fatalf("removed background startup unexpectedly succeeded: %+v", got)
|
||
}
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("removed background startup did not settle")
|
||
}
|
||
if host.HasClient(spec.Name) || len(host.ServerNames()) != 0 {
|
||
t.Fatalf("removed background server was resurrected: %v", host.ServerNames())
|
||
}
|
||
}
|
||
|
||
func TestStdioUsesConfiguredPATHForCommandLookup(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
dir, command := helperLauncher(t, "mock-mcp")
|
||
t.Setenv("PATH", "")
|
||
|
||
host, tools, err := StartAll(ctx, []Spec{{
|
||
Name: "path",
|
||
Command: command,
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"PATH": dir,
|
||
},
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("StartAll: %v", err)
|
||
}
|
||
defer host.Close()
|
||
if len(tools) != 2 {
|
||
t.Fatalf("want helper tools, got %d", len(tools))
|
||
}
|
||
}
|
||
|
||
func TestStdioFallsBackToShellPATHForCommandLookup(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
dir, command := helperLauncher(t, "shell-mcp")
|
||
t.Setenv("PATH", "")
|
||
old := stdioShellPATH
|
||
stdioShellPATH = func(context.Context) string { return dir }
|
||
t.Cleanup(func() { stdioShellPATH = old })
|
||
|
||
host, tools, err := StartAll(ctx, []Spec{{
|
||
Name: "shell-path",
|
||
Command: command,
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("StartAll: %v", err)
|
||
}
|
||
defer host.Close()
|
||
if len(tools) != 2 {
|
||
t.Fatalf("want helper tools, got %d", len(tools))
|
||
}
|
||
}
|
||
|
||
func TestStdioCommandNotFoundSuggestsPATHFix(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
t.Setenv("PATH", "")
|
||
old := stdioShellPATH
|
||
stdioShellPATH = func(context.Context) string { return "" }
|
||
t.Cleanup(func() { stdioShellPATH = old })
|
||
|
||
host, _ := StartAvailable(ctx, []Spec{{Name: "missing", Command: "reasonix-missing-mcp-binary"}})
|
||
defer host.Close()
|
||
|
||
failures := host.Failures()
|
||
if len(failures) != 1 {
|
||
t.Fatalf("failures = %+v, want one", failures)
|
||
}
|
||
msg := failures[0].Error
|
||
for _, want := range []string{
|
||
`command "reasonix-missing-mcp-binary" not found on PATH`,
|
||
"absolute command path",
|
||
"MCP server env",
|
||
} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Fatalf("failure %q missing %q", msg, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStdioIgnoresRelativePATHEntries(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "bin")
|
||
if err := os.Mkdir(bin, 0o755); err != nil {
|
||
t.Fatalf("mkdir bin: %v", err)
|
||
}
|
||
name := "mock-mcp"
|
||
target := filepath.Join(bin, name)
|
||
env := []string{"PATH=bin"}
|
||
if runtime.GOOS == "windows" {
|
||
target += ".cmd"
|
||
env = append(env, "PATHEXT=.CMD")
|
||
}
|
||
if err := os.WriteFile(target, []byte(""), 0o755); err != nil {
|
||
t.Fatalf("write fake executable: %v", err)
|
||
}
|
||
t.Chdir(dir)
|
||
|
||
if exe, ok := lookPathInEnv(name, env); ok {
|
||
t.Fatalf("relative PATH entry resolved to %q; want no match", exe)
|
||
}
|
||
}
|
||
|
||
func helperLauncher(t *testing.T, name string) (dir, command string) {
|
||
t.Helper()
|
||
if runtime.GOOS == "windows" {
|
||
t.Skip("shell launcher fixture is POSIX-only")
|
||
}
|
||
dir = t.TempDir()
|
||
command = name
|
||
target := filepath.Join(dir, name)
|
||
script := "#!/bin/sh\nexec " + shellQuote(os.Args[0]) + " \"$@\"\n"
|
||
if err := os.WriteFile(target, []byte(script), 0o755); err != nil {
|
||
t.Fatalf("write helper launcher: %v", err)
|
||
}
|
||
return dir, command
|
||
}
|
||
|
||
func shellQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||
}
|
||
|
||
// TestStartPolicyConcurrencyCap verifies the semaphore-style cap: with
|
||
// Concurrency=1 the handshakes must serialise even though every spec runs
|
||
// in its own goroutine. We sleep briefly inside each helper's initialize so
|
||
// the goroutines have a chance to overlap if the cap is broken, then assert
|
||
// that observed max-in-flight never exceeded 1.
|
||
func TestStartPolicyConcurrencyCap(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
mk := func(name string) Spec {
|
||
return Spec{
|
||
Name: name,
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "50",
|
||
},
|
||
}
|
||
}
|
||
specs := []Spec{mk("a"), mk("b"), mk("c"), mk("d")}
|
||
t0 := time.Now()
|
||
host, tools, err := Start(ctx, specs, StartPolicy{Concurrency: 1, AbortOnError: true})
|
||
if err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer host.Close()
|
||
elapsed := time.Since(t0)
|
||
// 4 specs × 50ms init each, serialised. Allow generous slack for CI.
|
||
if elapsed < 4*50*time.Millisecond {
|
||
t.Fatalf("with Concurrency=1, total time should be ≥ Σ(per-spec) but was %v", elapsed)
|
||
}
|
||
if len(tools) != 4*2 { // helper exposes 2 tools per server
|
||
t.Fatalf("want %d tools, got %d", 4*2, len(tools))
|
||
}
|
||
}
|
||
|
||
// TestStartPolicyPerPluginTimeout verifies that one slow plugin can't take
|
||
// down the whole batch in StartAvailable mode: the slow spec times out and
|
||
// gets recorded as a failure while the fast one connects.
|
||
func TestStartPolicyPerPluginTimeout(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
fast := Spec{
|
||
Name: "fast",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||
}
|
||
slow := Spec{
|
||
Name: "slow",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "5000", // 5s, well past the 2s budget
|
||
},
|
||
}
|
||
host, tools, err := Start(ctx, []Spec{fast, slow}, StartPolicy{
|
||
PerPluginTimeout: 2 * time.Second,
|
||
Concurrency: 2,
|
||
AbortOnError: false,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Start should not return err in record-failure mode: %v", err)
|
||
}
|
||
defer host.Close()
|
||
// Regression: the per-plugin timeout context must NOT bound the long-lived
|
||
// stdio child. If transport was bound to cctx instead of the parent ctx, the
|
||
// goroutine's deferred cancel would kill `fast`'s subprocess at handshake
|
||
// success and this Execute would fail. We invoke it explicitly here so any
|
||
// future re-introduction of the bug breaks loudly.
|
||
if len(tools) > 0 {
|
||
if _, callErr := tools[0].Execute(ctx, json.RawMessage(`{"msg":"hi"}`)); callErr != nil {
|
||
t.Fatalf("fast plugin's subprocess was killed by deferred timeout cancel: %v", callErr)
|
||
}
|
||
}
|
||
if len(tools) != 2 { // fast contributes 2 tools
|
||
t.Fatalf("want only fast's 2 tools, got %d", len(tools))
|
||
}
|
||
failures := host.Failures()
|
||
if len(failures) != 1 || failures[0].Name != "slow" {
|
||
t.Fatalf("failures = %+v, want [slow]", failures)
|
||
}
|
||
}
|
||
|
||
func TestStartRecordsTimeoutStats(t *testing.T) {
|
||
withTempCache(t)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
slow := Spec{
|
||
Name: "slow-stats",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_INIT_MS": "300",
|
||
},
|
||
}
|
||
for i := range 3 {
|
||
host, _, err := Start(ctx, []Spec{slow}, StartPolicy{
|
||
PerPluginTimeout: 50 * time.Millisecond,
|
||
Concurrency: 1,
|
||
AbortOnError: false,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Start #%d: %v", i, err)
|
||
}
|
||
host.Close()
|
||
}
|
||
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for {
|
||
rec := Recommend("slow-stats", 50*time.Millisecond, 3)
|
||
if rec.Demote {
|
||
return
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("timeout samples did not trigger demote; stats=%+v rec=%+v", readStats(t, "slow-stats"), rec)
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// TestStartPhaseAReturnsBeforePhaseB pins the two-phase handshake contract.
|
||
// The helper advertises prompts and stalls prompts/list by 200ms; StartAvailable
|
||
// must return with tools ready while the prompts surface is still empty, and the
|
||
// prompts must only materialise on Host after StartPhaseB has been called and
|
||
// drained — proving prompts ride the background phase, not the boot critical path.
|
||
func TestStartPhaseAReturnsBeforePhaseB(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
spec := Spec{
|
||
Name: "mock",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_PROMPTS": "1",
|
||
"GO_WANT_HELPER_PROMPT_DELAY_MS": "200",
|
||
},
|
||
}
|
||
|
||
host, tools := StartAvailable(ctx, []Spec{spec})
|
||
defer host.Close()
|
||
|
||
if len(tools) == 0 {
|
||
t.Fatalf("want tools from helper, got 0")
|
||
}
|
||
// Phase A returns with tools but the prompts surface must still be empty:
|
||
// StartAvailable never issues prompts/list (the helper stalls it 200ms), so
|
||
// prompts can only appear after StartPhaseB drains them below. We assert this
|
||
// deferral directly instead of timing StartAvailable — subprocess spawn plus
|
||
// the MCP handshake make a wall-clock threshold flaky on slow CI runners.
|
||
if got := host.Prompts(); len(got) != 0 {
|
||
t.Fatalf("phase A must not surface prompts yet, got %d", len(got))
|
||
}
|
||
|
||
// Drive phase B and wait for the surface-ready event. Use a buffered channel
|
||
// sink so the test never blocks the emitter — the event payload itself is
|
||
// our completion signal.
|
||
ready := make(chan event.Event, 4)
|
||
host.StartPhaseB(ctx, event.FuncSink(func(e event.Event) {
|
||
if e.Kind == event.MCPSurfaceReady {
|
||
select {
|
||
case ready <- e:
|
||
default:
|
||
}
|
||
}
|
||
}))
|
||
|
||
select {
|
||
case e := <-ready:
|
||
if !strings.Contains(e.Text, "prompts ready") {
|
||
t.Fatalf("phase B event text = %q, want it to mention prompts", e.Text)
|
||
}
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("phase B never fired MCPSurfaceReady for prompts")
|
||
}
|
||
|
||
if got := host.Prompts(); len(got) != 1 || got[0].Raw != "hello" {
|
||
t.Fatalf("after phase B, prompts = %+v, want one named hello", got)
|
||
}
|
||
}
|
||
|
||
func TestStartPhaseBDoesNotBlockToolCalls(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
spec := Spec{
|
||
Name: "mock",
|
||
Command: os.Args[0],
|
||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_PROMPTS": "1",
|
||
"GO_WANT_HELPER_PROMPT_DELAY_MS": "1000",
|
||
},
|
||
}
|
||
|
||
host, tools := StartAvailable(ctx, []Spec{spec})
|
||
defer host.Close()
|
||
|
||
var echo tool.Tool
|
||
for _, t := range tools {
|
||
if t.Name() == "mcp__mock__echo" {
|
||
echo = t
|
||
break
|
||
}
|
||
}
|
||
if echo == nil {
|
||
t.Fatal("missing echo tool")
|
||
}
|
||
|
||
host.StartPhaseB(ctx, event.Discard)
|
||
time.Sleep(50 * time.Millisecond)
|
||
|
||
callCtx, callCancel := context.WithTimeout(ctx, 150*time.Millisecond)
|
||
defer callCancel()
|
||
out, err := echo.Execute(callCtx, json.RawMessage(`{"msg":"hi"}`))
|
||
if err != nil {
|
||
t.Fatalf("tool call should not be blocked by background prompts/list: %v", err)
|
||
}
|
||
if out != "echo: hi" {
|
||
t.Fatalf("Execute result = %q, want %q", out, "echo: hi")
|
||
}
|
||
}
|
||
|
||
// TestHelperProcess is not a real test; it acts as a minimal MCP stdio server
|
||
// when invoked by TestStdioEndToEnd. It exits before the test framework can
|
||
// print to stdout, keeping the JSON-RPC channel clean.
|
||
//
|
||
// GO_WANT_HELPER_INIT_MS optionally injects a sleep before responding to the
|
||
// initialize call, used by the timeout / concurrency tests to simulate slow
|
||
// handshakes without depending on external processes.
|
||
// GO_WANT_HELPER_PROMPTS advertises the prompts capability and registers a
|
||
// "hello" prompt; GO_WANT_HELPER_PROMPT_DELAY_MS stalls prompts/list so the
|
||
// phase-A vs phase-B split can be exercised.
|
||
func TestHelperProcess(t *testing.T) {
|
||
if os.Getenv("GO_WANT_HELPER_STDERR_EXIT") == "1" {
|
||
os.Stderr.WriteString("helper stderr boom\n")
|
||
os.Exit(2)
|
||
}
|
||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||
return
|
||
}
|
||
defer os.Exit(0)
|
||
incrementHelperCounter(os.Getenv("GO_WANT_HELPER_START_COUNT"))
|
||
if msg := os.Getenv("GO_WANT_HELPER_STARTUP_STDERR"); msg != "" {
|
||
_, _ = os.Stderr.WriteString(msg + "\n")
|
||
}
|
||
|
||
var initDelay time.Duration
|
||
if ms := os.Getenv("GO_WANT_HELPER_INIT_MS"); ms != "" {
|
||
if v, err := time.ParseDuration(ms + "ms"); err == nil {
|
||
initDelay = v
|
||
}
|
||
}
|
||
|
||
in := bufio.NewReader(os.Stdin)
|
||
var outMu sync.Mutex
|
||
respond := func(id int, method string, params json.RawMessage) {
|
||
var result any
|
||
switch method {
|
||
case "server/discover":
|
||
response := map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{
|
||
"code": -32601, "message": "Method not found",
|
||
}}
|
||
body, _ := json.Marshal(response)
|
||
outMu.Lock()
|
||
_, _ = os.Stdout.Write(append(body, '\n'))
|
||
outMu.Unlock()
|
||
return
|
||
case "initialize":
|
||
if initDelay > 0 {
|
||
time.Sleep(initDelay)
|
||
}
|
||
caps := map[string]any{}
|
||
if os.Getenv("GO_WANT_HELPER_PROMPTS") == "1" {
|
||
caps["prompts"] = map[string]any{}
|
||
}
|
||
result = map[string]any{
|
||
"protocolVersion": testLegacyProtocolVersion,
|
||
"serverInfo": map[string]any{"name": "mock", "version": "0"},
|
||
"capabilities": caps,
|
||
}
|
||
case "prompts/list":
|
||
if ms := os.Getenv("GO_WANT_HELPER_PROMPT_DELAY_MS"); ms != "" {
|
||
if value, err := time.ParseDuration(ms + "ms"); err == nil && value > 0 {
|
||
time.Sleep(value)
|
||
}
|
||
}
|
||
result = map[string]any{"prompts": []map[string]any{{
|
||
"name": "hello", "description": "say hi", "arguments": []map[string]any{},
|
||
}}}
|
||
case "tools/list":
|
||
result = map[string]any{"tools": []map[string]any{{
|
||
"name": "zed", "description": "Sorted after echo.", "inputSchema": map[string]any{"type": "object"},
|
||
}, {
|
||
"name": "echo", "description": "Echo back the message.",
|
||
"inputSchema": map[string]any{
|
||
"type": "object", "properties": map[string]any{"msg": map[string]any{"type": "string"}},
|
||
"required": []string{"z", "msg"},
|
||
},
|
||
}}}
|
||
case "tools/call":
|
||
incrementHelperCounter(os.Getenv("GO_WANT_HELPER_CALL_COUNT"))
|
||
var call struct {
|
||
Arguments struct {
|
||
Msg string `json:"msg"`
|
||
} `json:"arguments"`
|
||
}
|
||
_ = json.Unmarshal(params, &call)
|
||
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "echo: " + call.Arguments.Msg}}}
|
||
}
|
||
response := map[string]any{"jsonrpc": "2.0", "id": id, "result": result}
|
||
body, _ := json.Marshal(response)
|
||
outMu.Lock()
|
||
_, _ = os.Stdout.Write(append(body, '\n'))
|
||
outMu.Unlock()
|
||
}
|
||
for {
|
||
line, err := in.ReadBytes('\n')
|
||
if err != nil {
|
||
return
|
||
}
|
||
line = bytes.TrimSpace(line)
|
||
if len(line) == 0 {
|
||
continue
|
||
}
|
||
|
||
var req struct {
|
||
ID *int `json:"id"`
|
||
Method string `json:"method"`
|
||
Params json.RawMessage `json:"params"`
|
||
}
|
||
if err := json.Unmarshal(line, &req); err != nil {
|
||
continue
|
||
}
|
||
if req.ID == nil {
|
||
continue // notification: no response
|
||
}
|
||
go respond(*req.ID, req.Method, append(json.RawMessage(nil), req.Params...))
|
||
}
|
||
}
|
||
|
||
func incrementHelperCounter(path string) int {
|
||
if strings.TrimSpace(path) == "" {
|
||
return 0
|
||
}
|
||
value := 0
|
||
if body, err := os.ReadFile(path); err == nil {
|
||
value, _ = strconv.Atoi(strings.TrimSpace(string(body)))
|
||
}
|
||
value++
|
||
_ = os.WriteFile(path, []byte(strconv.Itoa(value)), 0o600)
|
||
return value
|
||
}
|
||
|
||
func readHelperCounter(t *testing.T, path string) int {
|
||
t.Helper()
|
||
body, err := os.ReadFile(path)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return 0
|
||
}
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
value, err := strconv.Atoi(strings.TrimSpace(string(body)))
|
||
if err != nil {
|
||
t.Fatalf("parse helper counter %q: %v", body, err)
|
||
}
|
||
return value
|
||
}
|
||
|
||
func TestStdioWriterPreservesPersistentProcessByDefault(t *testing.T) {
|
||
stateDir := t.TempDir()
|
||
startCount := filepath.Join(t.TempDir(), "starts")
|
||
callCount := filepath.Join(t.TempDir(), "calls")
|
||
spec := Spec{
|
||
Name: "stateful-writer", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_START_COUNT": startCount,
|
||
"GO_WANT_HELPER_CALL_COUNT": callCount,
|
||
},
|
||
StateDir: stateDir,
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer host.Close()
|
||
writer := findToolByName(tools, "mcp__stateful-writer__echo")
|
||
if writer == nil {
|
||
t.Fatalf("writer tool missing from %v", toolNames(tools))
|
||
}
|
||
if _, err := writer.Execute(ctx, json.RawMessage(`{"msg":"one","z":"ok"}`)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := writer.Execute(ctx, json.RawMessage(`{"msg":"two","z":"ok"}`)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 1 {
|
||
t.Fatalf("process starts = %d, want one persistent MCP process", got)
|
||
}
|
||
if got := readHelperCounter(t, callCount); got != 2 {
|
||
t.Fatalf("tool calls = %d, want two calls on the persistent process", got)
|
||
}
|
||
}
|
||
|
||
func TestValidateMCPToolNamesRejectsAmbiguousLists(t *testing.T) {
|
||
for name, tools := range map[string][]mcpTool{
|
||
"empty": {{Name: " "}},
|
||
"duplicate": {{Name: "read"}, {Name: "read"}},
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
if err := validateMCPToolNames(tools); err == nil {
|
||
t.Fatalf("validateMCPToolNames(%+v) succeeded", tools)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNormalizeIdentityURLPreservesEndpointSemantics(t *testing.T) {
|
||
a := normalizeIdentityURL("HTTPS://alice:[email protected]:443/mcp?access_token=abc&workspace=one#fragment")
|
||
b := normalizeIdentityURL("https://bob:[email protected]/mcp?workspace=two&access_token=xyz")
|
||
if a == b {
|
||
t.Fatalf("different endpoint credentials/query values collapsed to one identity URL: %q", a)
|
||
}
|
||
if strings.Contains(a, "#fragment") {
|
||
t.Fatalf("identity URL retained non-semantic fragment: %q", a)
|
||
}
|
||
}
|
||
|
||
func TestWorkspaceIdentityIgnoresHostPolicyChanges(t *testing.T) {
|
||
base := Spec{
|
||
Name: "custom", Command: os.Args[0], ConfigSource: "workspace_config",
|
||
Sandbox: sandbox.Spec{Mode: "enforce", ForbidReadRoots: []string{"/secret/a"}},
|
||
}
|
||
changed := base
|
||
changed.Sandbox.ForbidReadRoots = []string{"/secret/b"}
|
||
a, err := projectLaunchIdentityDigest(context.Background(), base)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := projectLaunchIdentityDigest(context.Background(), changed)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a != b {
|
||
t.Fatal("host sandbox policy change altered stable server identity")
|
||
}
|
||
}
|
||
|
||
func TestProjectLaunchApprovalBlocksBeforeProcessStart(t *testing.T) {
|
||
redirectCache(t)
|
||
startCount := filepath.Join(t.TempDir(), "starts")
|
||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
|
||
spec := Spec{
|
||
Name: "project-server", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_START_COUNT": startCount,
|
||
},
|
||
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
if _, _, err := StartAll(ctx, []Spec{spec}); err == nil || !strings.Contains(err.Error(), "until the user authorizes") {
|
||
t.Fatalf("unauthorized project start error = %v", err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 0 {
|
||
t.Fatalf("unauthorized project starts = %d, want 0", got)
|
||
}
|
||
if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 0 {
|
||
t.Fatalf("launch authorization started project %d times, want 0", got)
|
||
}
|
||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(tools) == 0 {
|
||
t.Fatal("authorized project server returned no tools")
|
||
}
|
||
host.Close()
|
||
if got := readHelperCounter(t, startCount); got != 1 {
|
||
t.Fatalf("post-authorization starts = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
func TestAuthorizeSpecLaunchRecordsInstallConsentWithoutStartingServer(t *testing.T) {
|
||
redirectCache(t)
|
||
startCount := filepath.Join(t.TempDir(), "starts")
|
||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
|
||
spec := Spec{
|
||
Name: "installed-project-server", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_START_COUNT": startCount,
|
||
},
|
||
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||
t.Fatalf("AuthorizeSpecLaunch: %v", err)
|
||
}
|
||
if resolved := ResolveStoredAuthorization(ctx, spec); !resolved.ServerAuthorized() {
|
||
t.Fatal("stored project launch grant did not resolve server authorization")
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 0 {
|
||
t.Fatalf("install authorization started server %d times, want 0", got)
|
||
}
|
||
identity, err := projectLaunchIdentityDigest(ctx, spec)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
|
||
if err != nil || !authorized || changed {
|
||
t.Fatalf("installed launch grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
|
||
}
|
||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||
if err != nil {
|
||
t.Fatalf("start installed project server: %v", err)
|
||
}
|
||
defer host.Close()
|
||
if len(tools) == 0 {
|
||
t.Fatal("installed project server returned no tools")
|
||
}
|
||
}
|
||
|
||
func TestAuthorizeSpecLaunchDoesNotAddPersistentTransportRestrictions(t *testing.T) {
|
||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
|
||
spec := Spec{
|
||
Name: "installed-local-http", Type: "http", URL: "http://127.0.0.1:8080/mcp",
|
||
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
|
||
}
|
||
ctx := context.Background()
|
||
if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||
t.Fatalf("explicit install authorization: %v", err)
|
||
}
|
||
identity, err := projectLaunchIdentityDigest(ctx, spec)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
|
||
if err != nil || !authorized || changed {
|
||
t.Fatalf("installed local HTTP grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
|
||
}
|
||
}
|
||
|
||
func TestAuthorizeProjectSpecLaunchLocksMutableLauncherWithoutStartingServer(t *testing.T) {
|
||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
|
||
launcher := filepath.Join(t.TempDir(), "npx")
|
||
if runtime.GOOS == "windows" {
|
||
launcher += ".exe"
|
||
}
|
||
if err := os.WriteFile(launcher, []byte("launcher fixture"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
commit := "0123456789abcdef0123456789abcdef01234567"
|
||
locator := "git+https://example.invalid/server.git@" + commit
|
||
spec := Spec{
|
||
Name: "repository-server", Command: launcher, Args: []string{locator},
|
||
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
|
||
}
|
||
if err := AuthorizeProjectSpecLaunch(context.Background(), spec); err != nil {
|
||
t.Fatalf("AuthorizeProjectSpecLaunch: %v", err)
|
||
}
|
||
lock, found, err := manager.GetLauncherLock(spec.Name, digestText(locator))
|
||
if err != nil || !found || lock.ResolvedVersion != commit {
|
||
t.Fatalf("project launcher lock = (%+v, found=%v, err=%v)", lock, found, err)
|
||
}
|
||
locked, err := applyStoredLauncherLock(spec)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
identity, err := projectLaunchIdentityDigest(context.Background(), locked)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
|
||
if err != nil || !authorized || changed {
|
||
t.Fatalf("project launch grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
|
||
}
|
||
}
|
||
|
||
func TestReaderIntentRefusesDispatchAfterSafetyDrift(t *testing.T) {
|
||
stateDir := t.TempDir()
|
||
startCount := filepath.Join(t.TempDir(), "starts")
|
||
callCount := filepath.Join(t.TempDir(), "calls")
|
||
spec := Spec{
|
||
Name: "reader-revoked", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
|
||
Env: map[string]string{
|
||
"GO_WANT_HELPER_PROCESS": "1",
|
||
"GO_WANT_HELPER_START_COUNT": startCount,
|
||
"GO_WANT_HELPER_CALL_COUNT": callCount,
|
||
},
|
||
StateDir: stateDir, Authorized: true,
|
||
LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer host.Close()
|
||
host.bgWrites.Wait()
|
||
target := findToolByName(tools, "mcp__reader-revoked__echo")
|
||
if target == nil {
|
||
t.Fatalf("tool missing from %v", toolNames(tools))
|
||
}
|
||
rt, ok := target.(*remoteTool)
|
||
if !ok {
|
||
t.Fatalf("expected remoteTool adapter, got %T", target)
|
||
}
|
||
// The installed server is authorized and currently advertises a reader.
|
||
rt.client.toolsMu.Lock()
|
||
rt.readOnly = true
|
||
rt.client.toolsMu.Unlock()
|
||
readerCtx := tool.WithReaderExecutionIntent(ctx)
|
||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
|
||
t.Fatalf("authorized reader call failed: %v", err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 1 {
|
||
t.Fatalf("reader call spawned extra processes: starts=%d", got)
|
||
}
|
||
if got := readHelperCounter(t, callCount); got != 1 {
|
||
t.Fatalf("reader call count = %d, want 1", got)
|
||
}
|
||
|
||
// A concurrent read-to-write classification change lands after authorization:
|
||
// the reader-authorized call must refuse instead of issuing tools/call.
|
||
rt.client.toolsMu.Lock()
|
||
rt.readOnly = false
|
||
rt.client.toolsMu.Unlock()
|
||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"blocked","z":"ok"}`)); err == nil || !strings.Contains(err.Error(), "changed the authorization or security metadata") {
|
||
t.Fatalf("changed reader call = %v, want reader refusal", err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 1 {
|
||
t.Fatalf("revoked reader call started a writer process: starts=%d", got)
|
||
}
|
||
if got := readHelperCounter(t, callCount); got != 1 {
|
||
t.Fatalf("revoked reader call reached tools/call: calls=%d", got)
|
||
}
|
||
|
||
// Schema-only changes do not revoke an installed server or its reader lane.
|
||
// The live server owns argument validation; refreshed provider-visible schema
|
||
// bytes land in the next session rather than interrupting this call.
|
||
rt.client.toolsMu.Lock()
|
||
rt.readOnly = true
|
||
rt.client.toolsMu.Unlock()
|
||
rt.schema = json.RawMessage(`{"type":"object","properties":{"msg":{"type":"number"}}}`)
|
||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"schema-changed","z":"ok"}`)); err != nil {
|
||
t.Fatalf("schema-only reader change should execute: %v", err)
|
||
}
|
||
if got := readHelperCounter(t, callCount); got != 2 {
|
||
t.Fatalf("schema-only reader call count = %d, want 2", got)
|
||
}
|
||
|
||
// Without reader intent the ordinary writer path remains on the persistent
|
||
// connection.
|
||
rt.client.toolsMu.Lock()
|
||
rt.readOnly = false
|
||
rt.client.toolsMu.Unlock()
|
||
if _, _, err := rt.ExecuteWithImages(ctx, json.RawMessage(`{"msg":"writer","z":"ok"}`)); err != nil {
|
||
t.Fatalf("authorized writer call failed: %v", err)
|
||
}
|
||
if got := readHelperCounter(t, startCount); got != 1 {
|
||
t.Fatalf("writer call starts = %d, want one persistent process", got)
|
||
}
|
||
if got := readHelperCounter(t, callCount); got != 3 {
|
||
t.Fatalf("writer call count = %d, want 3", got)
|
||
}
|
||
}
|