// Package plugin is Reasonix's MCP client. It connects to external MCP servers and
// adapts their tools to the tool.Tool interface, so the agent treats plugin tools
// and built-ins uniformly. The official MCP Go SDK owns protocol negotiation and
// JSON-RPC sessions across stdio, Streamable HTTP, and legacy HTTP+SSE; Reasonix
// retains product policy, lifecycle supervision, and transport security.
package plugin

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"io"
	"log/slog"
	"maps"
	"net/http"
	"reflect"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"reasonix/internal/event"
	"reasonix/internal/mcplaunch"
	"reasonix/internal/sandbox"
	"reasonix/internal/secrets"
	"reasonix/internal/tool"
)

// MCPProcessMode selects how a local stdio MCP process is launched.
// It is an internal runtime field, not a user-facing config knob.
type MCPProcessMode string

const (
	// MCPProcessHost runs authorized stdio MCP as a trusted host process that
	// does not inherit the agent Bash command sandbox. This is the product
	// default so servers such as chrome-devtools-mcp can reach the real browser,
	// Keychain, LaunchServices, and local app services.
	MCPProcessHost MCPProcessMode = "host"
	// MCPProcessConfined wraps the process with sandbox.CommandArgs. Reserved for
	// internal managed deployments and tests; never auto-selected for user installs.
	MCPProcessConfined MCPProcessMode = "confined"
)

// ResolvedProcessMode returns the effective process mode. Empty means host.
func (s Spec) ResolvedProcessMode() MCPProcessMode {
	switch s.ProcessMode {
	case MCPProcessConfined:
		return MCPProcessConfined
	default:
		return MCPProcessHost
	}
}

// defaultCallTimeout is the MCP JSON-RPC call deadline applied when neither the
// caller context nor config provides one. It is intentionally finite so a slow
// or hung MCP server cannot block an agent turn indefinitely.
const defaultCallTimeout = 300 * time.Second

// Spec declares an external MCP server. Type selects the transport: "stdio"
// (default) runs Command/Args/Env as a subprocess; "http" / "streamable-http"
// and "sse" connect to URL with optional static Headers.
type Spec struct {
	Name string
	// Package is the installed plugin package that contributed this server.
	// It is host-only provenance and intentionally excluded from fingerprints.
	Package string
	Type    string
	Command string
	Args    []string
	Env     map[string]string
	URL     string
	Headers map[string]string
	// DefaultStartupTimeout is the background initialize + tools/list safety cap
	// for this server. Zero keeps Reasonix's built-in default.
	DefaultStartupTimeout time.Duration
	// StartupTimeout overrides DefaultStartupTimeout for this server. It is
	// host-only lifecycle policy and never changes provider-visible tool schemas.
	StartupTimeout time.Duration
	// DefaultCallTimeout is the global MCP call cap for this server. Zero keeps
	// Reasonix's built-in defaultCallTimeout.
	DefaultCallTimeout time.Duration
	// CallTimeout overrides DefaultCallTimeout for all calls to this server.
	// Zero falls back to DefaultCallTimeout.
	CallTimeout time.Duration
	// ToolTimeouts overrides the per-call deadline for raw MCP tool names.
	// Keys are server-local tool names as returned by tools/list, not the
	// model-visible mcp__server__tool names.
	ToolTimeouts map[string]time.Duration
	// Dir, when set, is the working directory of a stdio subprocess. Empty means
	// inherit reasonix's cwd (the default for user-configured plugins). It exists
	// for cwd-aware servers like CodeGraph, which detect the project from the
	// directory they are launched in — they must be pinned to the project root.
	Dir string
	// WorkspaceRoot is the project root exposed through the MCP roots capability.
	// It is runtime-only and intentionally separate from Dir: user-installed
	// stdio servers keep inheriting Reasonix's cwd while still receiving the
	// explicit workspace root when they ask for roots/list.
	WorkspaceRoot string
	// Stderr optionally mirrors plugin subprocess stderr output. Stderr is always
	// captured in a bounded buffer for failure diagnostics; nil keeps it out of
	// the terminal so child logs cannot corrupt interactive UIs.
	Stderr io.Writer
	// LaunchManager owns exact project launch grants and mutable launcher locks.
	// It never contributes to SchemaCacheKey or provider-visible tool schemas.
	LaunchManager *mcplaunch.Manager
	// ConfigSource disambiguates otherwise identical server names coming from
	// workspace config, a host transport, or a user-installed plugin package.
	ConfigSource string
	// Authorized is the single runtime authorization result for this server.
	// User-installed and explicit host-session servers set it directly; project
	// servers set it only after an exact launch grant is resolved.
	Authorized            bool
	RequireLaunchApproval bool
	// LaunchArgs and launcher metadata are host-local immutable resolutions for
	// mutable package launchers. LauncherIdentityArgs is the same exact package
	// resolution without an automatically injected offline/no-install flag: that
	// enforcement-only flag changes process invocation but not the server identity
	// the user approved. These fields never contribute to SchemaCacheKey or the
	// provider-visible tool surface; Args remains the user's stable config.
	LaunchArgs              []string
	LauncherIdentityArgs    []string
	LauncherLocator         string
	LauncherResolvedVersion string
	LauncherDigest          string
	// ProcessMode selects host mode (default) or confined mode, which is reserved
	// for internal managed deployments and tests, never an automatic fallback.
	ProcessMode MCPProcessMode
	// Sandbox is only applied when ProcessMode is confined. Host-mode servers
	// keep private state/cache/temp dirs without wrapping the process in the
	// agent command sandbox.
	Sandbox         sandbox.Spec
	StateDir        string
	OAuthHTTPClient *http.Client
	// StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
	// raw name before namespacing. For example, StripRawPrefix="server_" turns
	// "server_search" into "search", yielding "mcp__search__search" instead of
	// the redundant "mcp__search__server_search". The original raw name is
	// preserved for MCP protocol calls.
	StripRawPrefix string
	// LowPriority runs a stdio subprocess below normal scheduling priority, for
	// background indexers that must not starve the user's machine.
	LowPriority bool
}

// transport carries JSON-RPC messages to and from one MCP server. call sends a
// request and returns its result; close releases resources. Transports route MCP
// progress notifications to the active tool call and answer the client
// capabilities Reasonix advertises (currently ping and roots/list).
type transport interface {
	call(ctx context.Context, method string, params any) (json.RawMessage, error)
	close()
}

// Host owns the running plugin connections and closes them together. It also
// aggregates the prompts and resources discovered across servers, which the
// chat UI surfaces (prompts as slash commands, resources as @-references).
type Host struct {
	// mu guards the slices below: StartAll builds the Host single-threaded, but
	// after that a /mcp hot-add or -remove (one goroutine) can run concurrently
	// with reads from a running turn's @ref resolution or the status UI.
	mu        sync.RWMutex
	clients   []*Client
	prompts   []Prompt
	resources []Resource
	failures  []Failure
	closed    bool

	// nextInstanceID assigns stable IDs to Client values appended to this Host.
	// nextScopeID assigns IDs to per-build RegistrationScope tokens.
	nextInstanceID atomic.Uint64
	nextScopeID    atomic.Uint64

	// Lazy/background servers may still be handshaking when a session closes.
	// Close cancels those startup contexts and waits for their goroutines before
	// taking the client snapshot, so a just-connected stdio child cannot escape
	// teardown and keep a Windows workspace directory locked.
	deferredCancels     map[string][]context.CancelFunc
	deferredGenerations map[string]uint64
	deferredWG          sync.WaitGroup

	// spawningMu + spawning prevent concurrent spawns of the same server from
	// multiple callers (e.g. several controller tabs sharing one Host). The
	// owner publishes its result before closing done so waiters can reuse the
	// discovered tools without issuing concurrent tools/list calls.
	spawningMu sync.Mutex
	spawning   map[string]*spawnAttempt

	// proxies holds stable per-server backends for rolling replacement without
	// changing provider-visible tool prefixes (spatiotemporal composability).
	proxies map[string]*serverProxy

	// profile is the host's semantic client-capability surface, fixed at
	// creation; cache identity and the capability matrix derive from it.
	profile HostProfile

	// appInstances is the bounded MCP Apps instance registry, built with the
	// Host and never nil.
	appInstances *appInstanceRegistry

	// Detached stats/schema-cache writers from Start; off the boot path but
	// drained by Close so cleanup can't race a still-open cache file.
	bgWrites  sync.WaitGroup
	surfaceWG sync.WaitGroup

	toolListChanges toolListSubscriptions
}

// ReadResource reads a resource uri from the named server. It is how the chat
// UI resolves an @server:uri reference — the uri need not be one listed by
// resources/list (servers may expose templated uris), so we read it directly.
func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error) {
	h.mu.RLock()
	var target *Client
	for _, c := range h.clients {
		if c.name == server {
			target = c
			break
		}
	}
	h.mu.RUnlock()
	if target == nil {
		return "", fmt.Errorf("no MCP server named %q", server)
	}
	return target.readResource(ctx, uri) // network call: outside the lock
}

// StartPolicy tunes batch plugin startup. The zero value disables every safeguard,
// so most call sites should use the StartAll / StartAvailable wrappers, which
// fill in production defaults.
type StartPolicy struct {
	// PerPluginTimeout caps how long a single plugin's handshake (start +
	// initialize + listTools + listPrompts/Resources) may take. Zero disables.
	// Exceeded plugins are recorded as failures and, when AbortOnError is set,
	// tear down the whole batch with the timeout as the cause.
	PerPluginTimeout time.Duration

	// Concurrency caps how many handshakes run at once. Zero or negative means
	// no cap (every plugin gets a goroutine immediately). A small cap prevents
	// process storms / FD exhaustion when many MCP servers are configured.
	Concurrency int

	// AbortOnError makes any single failure tear down the partial batch and
	// return an error (StartAll semantics). When false, failures are recorded
	// on the host and other plugins keep going (StartAvailable semantics).
	AbortOnError bool

	// SkipPersistence disables RecordStartup / SaveCachedSchema side effects.
	// Use for read-only live probes (capability diagnostics) that must not
	// write MCP stats or schema cache files under Reasonix home.
	SkipPersistence bool
}

// defaultStartConcurrency caps parallel handshakes for the batch-start wrappers.
// Eight is the standard "process storm" guardrail (Bazel's --jobs=auto, most LSP
// managers) — large enough to mask single-plugin latency, small enough to spare
// a workstation with 20+ configured MCP servers from fork-bombing itself.
const defaultStartConcurrency = 8

// defaultStartTimeout is the per-plugin budget used by StartAvailable. Five
// seconds covers a healthy stdio MCP spawning under a slow npm/node loader; past
// that, an interactive user is better served by recording the failure and moving
// on than by stalling the whole session.
const defaultStartTimeout = 5 * time.Second

var advertisedToolsEmptyListRetryDelays = []time.Duration{
	50 * time.Millisecond,
	150 * time.Millisecond,
	300 * time.Millisecond,
}

// ErrServerAlreadyConnected marks an attempted MCP connection whose server name
// is already live on the host.
var ErrServerAlreadyConnected = errors.New("plugin server already connected")

func serverAlreadyConnectedError(name string) error {
	return fmt.Errorf("%w: %q", ErrServerAlreadyConnected, name)
}

// IsServerAlreadyConnected reports whether err means the MCP server name is
// already live on the host.
func IsServerAlreadyConnected(err error) bool {
	return errors.Is(err, ErrServerAlreadyConnected)
}

// StartAll connects every plugin in parallel, performs the MCP handshake, and
// returns the union of their tools (namespaced "mcp__<server>__<tool>"). On any
// failure it tears down everything started so far. The caller must Close the Host.
//
// For stdio plugins, subprocess lifetime is bound to ctx (via
// exec.CommandContext): cancelling ctx kills the children and unblocks reads.
func StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error) {
	return Start(ctx, specs, StartPolicy{
		Concurrency:  defaultStartConcurrency,
		AbortOnError: true,
	})
}

// StartAvailable connects every plugin it can and records failures on the host
// instead of aborting the whole session. The returned tools are the union of the
// successfully connected servers.
func StartAvailable(ctx context.Context, specs []Spec) (*Host, []tool.Tool) {
	h, tools, _ := Start(ctx, specs, StartPolicy{
		PerPluginTimeout: defaultStartTimeout,
		Concurrency:      defaultStartConcurrency,
		// AbortOnError stays false: a misconfigured plugin must not bring down
		// the whole session at boot.
	})
	return h, tools
}

// Start is the unified batch-startup primitive behind StartAll / StartAvailable.
// It fans out handshakes in parallel under the policy's concurrency cap, gives
// each plugin its own per-plugin timeout, and either aborts the batch on first
// failure (AbortOnError=true) or records failures on the host and keeps going.
//
// Result ordering matches specs (stable for /mcp status). For stdio plugins the
// subprocess is bound to the parent ctx, not the per-plugin startup timeout:
// successful servers stay alive after startup, while failed/time-limited starts
// are closed explicitly before the goroutine returns.
func Start(ctx context.Context, specs []Spec, p StartPolicy) (*Host, []tool.Tool, error) {
	if len(specs) == 0 {
		return &Host{}, nil, nil
	}

	type result struct {
		idx    int
		spec   Spec
		client *Client
		tools  []tool.Tool
		err    error
	}

	// A buffered channel acts as a counting semaphore. Capacity 0/negative
	// means no cap — we still launch one goroutine per spec, but they all run
	// immediately. Capped, the extra goroutines block on the semaphore until a
	// slot frees up; collection order is still by idx so /mcp status is stable.
	concurrency := p.Concurrency
	if concurrency <= 0 || concurrency > len(specs) {
		concurrency = len(specs)
	}
	sem := make(chan struct{}, concurrency)
	ch := make(chan result, len(specs))

	// Created before the fan-out so the detached cache writers can join bgWrites.
	h := &Host{}

	for i, s := range specs {
		go func(idx int, spec Spec) {
			sem <- struct{}{}
			defer func() { <-sem }()

			callCtx := ctx
			cancelStartup := func() {}
			if p.PerPluginTimeout > 0 {
				var cancel context.CancelFunc
				callCtx, cancel = context.WithTimeout(ctx, p.PerPluginTimeout)
				cancelStartup = cancel
			}

			phaseAStart := time.Now()
			recordedPhaseADur := func() time.Duration {
				dur := time.Since(phaseAStart)
				if p.PerPluginTimeout > 0 && callCtx.Err() == context.DeadlineExceeded && dur < p.PerPluginTimeout {
					return p.PerPluginTimeout
				}
				return dur
			}

			// Transport on the parent ctx, startup RPCs on the timed callCtx: the
			// per-plugin timeout caps initialize+listTools, but the long-lived
			// stdio child must outlive the startup scope and later phase-B calls.
			c, err := start(ctx, callCtx, spec, h.profile)
			if err != nil {
				phaseADur := recordedPhaseADur()
				cancelStartup()
				if !p.SkipPersistence {
					h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) })
				}
				ch <- result{idx: idx, spec: spec, err: fmt.Errorf("start plugin %q: %w", spec.Name, err)}
				return
			}
			h.bindToolListChanges(c)
			ts, err := c.listTools(callCtx)
			if err != nil {
				phaseADur := recordedPhaseADur()
				cancelStartup()
				if !p.SkipPersistence {
					h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) })
				}
				c.close()
				err = newStartupFailure("tools/list", phaseAStart, c.startupStderr(), err)
				ch <- result{idx: idx, spec: spec, err: fmt.Errorf("list tools from %q: %w", spec.Name, err)}
				return
			}
			// Persist for next launch on the side: a slow stats/cache write
			// must not delay tools coming online, and either failure is
			// recoverable (we just re-handshake or skip auto-demote).
			phaseADur := recordedPhaseADur()
			cancelStartup()
			if !p.SkipPersistence {
				h.bgWrites.Go(func() {
					_ = RecordStartup(spec.Name, phaseADur)
					_ = SaveCachedSchemaForProfile(h.profile, spec.Name, CachedSchema{
						CacheKey: SchemaCacheKey(spec),
						Capabilities: map[string]bool{
							"tools":     c.capabilities.tools,
							"prompts":   c.capabilities.prompts,
							"resources": c.capabilities.resources,
						},
						Tools: cacheableToolsOf(ts),
					})
				})
			}

			// Prompts and resources are deferred to StartPhaseB so the boot path
			// can return as soon as tools are ready — the slow-to-list surfaces
			// stream in later and fan out an MCPSurfaceReady event each.
			ch <- result{idx: idx, spec: spec, client: c, tools: ts}
		}(i, s)
	}

	// Wait for every goroutine even on abort: started clients sit beyond a
	// failing index, so we need them all back to tear them down in Close().
	results := make([]result, len(specs))
	for range specs {
		r := <-ch
		results[r.idx] = r
	}

	var tools []tool.Tool
	var firstErr error
	for _, r := range results {
		if r.err != nil {
			if p.AbortOnError {
				if firstErr == nil {
					firstErr = r.err
				}
			} else {
				h.RecordFailure(r.spec, r.err)
			}
			continue
		}
		current, err := h.registerStartedClient(r.client, r.tools)
		if err != nil {
			r.client.close()
			if firstErr == nil {
				firstErr = err
			}
			continue
		}
		tools = append(tools, current...)
		// prompts/resources are filled in later by StartPhaseB.
	}
	if firstErr != nil {
		h.Close()
		return nil, nil, firstErr
	}
	return h, tools, nil
}

// Close terminates all plugin connections.
func (h *Host) Close() {
	h.mu.Lock()
	if h.closed {
		h.mu.Unlock()
		return
	}
	h.closed = true
	var cancels []context.CancelFunc
	for _, serverCancels := range h.deferredCancels {
		cancels = append(cancels, serverCancels...)
	}
	h.deferredCancels = nil
	h.mu.Unlock()

	for _, cancel := range cancels {
		cancel()
	}
	h.deferredWG.Wait()

	h.mu.Lock()
	clients := append([]*Client(nil), h.clients...)
	proxies := h.proxies
	h.proxies = nil
	h.clients = nil
	h.toolListChanges.subscribers = nil
	h.mu.Unlock()
	closeServerProxies(proxies)
	for _, c := range clients {
		if c != nil && c.t != nil {
			c.close()
		}
	}
	h.surfaceWG.Wait()
	h.bgWrites.Wait() // drain detached stats/schema writers before returning
}

// queueBackgroundWrite keeps detached persistence inside the Host lifecycle.
// Callers must enqueue before their Close-drained startup owner completes, so
// Close cannot begin waiting before the WaitGroup increment is visible.
func (h *Host) queueBackgroundWrite(write func()) {
	h.bgWrites.Go(func() {
		write()
	})
}

func (h *Host) goSurface(work func()) bool {
	if h == nil || work == nil {
		return false
	}
	h.mu.Lock()
	if h.closed {
		h.mu.Unlock()
		return false
	}
	h.surfaceWG.Add(1)
	h.mu.Unlock()
	go func() {
		defer h.surfaceWG.Done()
		work()
	}()
	return true
}

// StartPhaseB asynchronously fetches the auxiliary surfaces (prompts and
// resources) for every connected client. Boot calls it right after Start
// returns, on a session-scoped ctx, so the agent becomes responsive as soon as
// tools are ready and the slower list calls stream in afterwards. Each finished
// surface fires an MCPSurfaceReady event on sink so UIs (e.g. /mcp status) can
// refresh without polling. A nil sink is tolerated — the merge still happens.
// Errors are logged and swallowed: prompts/resources are non-essential and must
// not break the session over one slow server.
func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink) {
	h.mu.RLock()
	clients := append([]*Client(nil), h.clients...)
	h.mu.RUnlock()
	for _, c := range clients {
		if c.capabilities.prompts {
			h.goSurface(func() { h.fetchPrompts(ctx, c, sink) })
		}
		if c.capabilities.resources {
			h.goSurface(func() { h.fetchResources(ctx, c, sink) })
		}
	}
}

func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) {
	auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout)
	defer cancel()

	ps, err := c.listPrompts(auxCtx)
	if err != nil {
		if ctx.Err() == nil && !c.closed.Load() {
			slog.Warn("plugin: listPrompts failed", "server", c.name, "err", err)
		}
		return
	}
	for i := range ps {
		ps[i].client = c
	}
	h.mu.Lock()
	if h.closed || h.lookupClientLocked(c.name) != c {
		h.mu.Unlock()
		return
	}
	h.removeClientPromptsLocked(c)
	c.prompts = ps
	h.prompts = append(h.prompts, ps...)
	h.mu.Unlock()
	if sink != nil {
		sink.Emit(event.Event{
			Kind: event.MCPSurfaceReady,
			Text: fmt.Sprintf("%s: prompts ready (%d items)", c.name, len(ps)),
		})
	}
}

func (h *Host) fetchResources(ctx context.Context, c *Client, sink event.Sink) {
	auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout)
	defer cancel()

	rs, err := c.listResources(auxCtx)
	if err != nil {
		if ctx.Err() == nil && !c.closed.Load() {
			slog.Warn("plugin: listResources failed", "server", c.name, "err", err)
		}
		return
	}
	h.mu.Lock()
	if h.closed || h.lookupClientLocked(c.name) != c {
		h.mu.Unlock()
		return
	}
	h.removeClientResourcesLocked(c)
	c.resources = rs
	h.resources = append(h.resources, rs...)
	h.mu.Unlock()
	if sink != nil {
		sink.Emit(event.Event{
			Kind: event.MCPSurfaceReady,
			Text: fmt.Sprintf("%s: resources ready (%d items)", c.name, len(rs)),
		})
	}
}

func (h *Host) removeClientPromptsLocked(c *Client) {
	kept := h.prompts[:0]
	for _, prompt := range h.prompts {
		if prompt.client != c {
			kept = append(kept, prompt)
		}
	}
	h.prompts = kept
}

func (h *Host) removeClientResourcesLocked(c *Client) {
	kept := h.resources[:0]
	for _, resource := range h.resources {
		if resource.Server != c.name {
			kept = append(kept, resource)
		}
	}
	h.resources = kept
}

// Client is one MCP server connection plus Reasonix's product-facing catalogs.
// MCP operations are transport-agnostic and go through the supervised SDK session.
type Client struct {
	name       string
	instanceID uint64 // Host-local identity for RemoveIfInstance rollback
	t          transport
	spec       Spec
	profile    HostProfile

	// registrationClaims and registrationCommitted are guarded by Host.mu.
	// Claims keep a tentative shared instance alive across overlapping builds;
	// the first published controller promotes it to ordinary Host ownership.
	registrationClaims    map[uint64]struct{}
	registrationCommitted bool

	// Advertised surface and list-changed capabilities are kept together so
	// initialization publishes one coherent capability snapshot.
	capabilities    clientCapabilities
	protocolVersion string
	transport       string // declared transport type, for /mcp status ("stdio"/"http")

	// Prompts and resources discovered during StartAll, stored here so the
	// parallel startup can collect them per-client before merging into Host.
	prompts   []Prompt
	resources []Resource
	// toolListFetchMu serializes tools/list requests. It may span the remote
	// request; toolsMu never does, so status readers cannot be stalled by MCP I/O.
	toolListFetchMu sync.Mutex
	toolsMu         sync.RWMutex
	toolCatalog     toolCatalogSnapshot

	// toolDispatchMu linearizes final adapter validation with tools/call and
	// catalog publication. A notification marks the catalog stale atomically,
	// so calls that have not entered this gate fail closed while it refreshes.
	toolDispatchMu    sync.RWMutex
	catalogGeneration uint64 // guarded by toolDispatchMu
	closed            atomic.Bool
	closeOnce         sync.Once
	refresh           toolListRefreshState
	surfaceStopsMu    sync.Mutex
	surfaceStops      []func()
	auxiliaryRefresh  auxiliaryListRefreshState
	progressID        atomic.Uint64
}

// ToolInfo is the human-facing metadata returned by MCP tools/list for one tool.
type ToolInfo struct {
	Name            string
	Description     string
	ReadOnlyHint    bool
	DestructiveHint bool
	SchemaError     string
}

// ServerStatus summarises one connected server for the /mcp command.
type ServerStatus struct {
	Name      string
	Transport string
	// ConfigSource is the config plane that registered this server
	// (user_config, project_config, workspace, built-in, …). Empty when unknown.
	// Surfaced in /mcp status so operators can tell where a tool came from (#6578).
	ConfigSource      string
	Tools             int
	Prompts           int
	Resources         int
	HasTools          bool
	ToolList          []ToolInfo
	ProtocolVersion   string
	SessionState      SessionState
	SessionIDPresent  bool
	ReconnectAttempts int
	LastErrorKind     SessionErrorKind
	LastError         string
	// HostProfile is the client-capability profile this host declares
	// ("core-v1", "interactive-v1", "desktop-apps-2026-01-26-v1").
	HostProfile string
	// ElicitationNegotiated reports that the client declared elicitation and
	// the session runs a protocol revision where the server can use it.
	ElicitationNegotiated bool
	// AppsNegotiated reports two-way MCP Apps agreement: the client declared
	// io.modelcontextprotocol/ui and the server answered with the extension.
	AppsNegotiated bool
}

// AuthorizeSpecLaunch records durable consent for an explicitly user-installed
// project MCP without starting it a second time. The normal project discovery
// path still requires a user action; install_source calls this only while
// applying a plan the user already requested. Reuse an existing launcher lock
// when one exists, but do not add a second network/version-resolution step to an
// explicit install: the durable grant follows the exact configured command or
// endpoint and future changes still invalidate it.
func AuthorizeSpecLaunch(ctx context.Context, spec Spec) error {
	return authorizeSpecLaunch(ctx, spec, false)
}

// AuthorizeProjectSpecLaunch records the one durable launch confirmation used
// for repository-discovered MCP configuration. Mutable package launchers are
// resolved and locked, but the MCP server itself is not started: the caller can
// connect it exactly once after this function returns.
func AuthorizeProjectSpecLaunch(ctx context.Context, spec Spec) error {
	return authorizeSpecLaunch(ctx, spec, true)
}

func authorizeSpecLaunch(ctx context.Context, spec Spec, lockMutableLauncher bool) error {
	if !spec.RequireLaunchApproval {
		return nil
	}
	manager := spec.LaunchManager
	if manager == nil {
		return fmt.Errorf("MCP launch authorization store is unavailable")
	}
	var prepared Spec
	var launcherLock *mcplaunch.LauncherLock
	var err error
	if lockMutableLauncher {
		prepared, launcherLock, err = preparePersistentLauncher(ctx, spec)
	} else {
		prepared, err = applyStoredLauncherLock(spec)
	}
	if err != nil {
		return err
	}
	identityDigest, err := projectLaunchIdentityDigest(ctx, prepared)
	if err != nil {
		return err
	}
	if launcherLock != nil {
		// Store the resolution before the grant so a failed state write cannot
		// leave an authorization whose exact launcher identity is unavailable.
		if err := manager.PutLauncherLock(*launcherLock); err != nil {
			return err
		}
	}
	return manager.Authorize(prepared.Name, launchConfigSource(prepared), identityDigest)
}

// Failure records one MCP server that was configured but could not connect.
type Failure struct {
	Name                   string
	Transport              string
	Error                  string
	Stage                  string
	Elapsed                time.Duration
	Stderr                 string
	RequiresLaunchApproval bool
}

type launchApprovalError struct {
	server  string
	changed bool
}

func (e *launchApprovalError) Error() string {
	if e.changed {
		return fmt.Sprintf("project-provided MCP server %q changed; blocked before process or network startup and requires explicit re-authorization", e.server)
	}
	return fmt.Sprintf("project-provided MCP server %q is blocked before process or network startup until the user authorizes it", e.server)
}

func requiresLaunchApproval(err error) bool {
	var launchTarget *launchApprovalError
	return errors.As(err, &launchTarget)
}

// Servers returns a status summary per connected server, in connection order.
func (h *Host) Servers() []ServerStatus {
	h.mu.RLock()
	defer h.mu.RUnlock()
	out := make([]ServerStatus, 0, len(h.clients))
	for _, c := range h.clients {
		c.toolsMu.RLock()
		s := ServerStatus{
			Name:         c.name,
			Transport:    c.transport,
			ConfigSource: strings.TrimSpace(c.spec.ConfigSource),
			Tools:        len(c.toolCatalog.adapters),
			HasTools:     c.capabilities.tools,
		}
		fillServerNegotiation(&s, h.profile, c)
		s.ToolList = append([]ToolInfo(nil), c.toolCatalog.infos...)
		c.toolsMu.RUnlock()
		if provider, ok := c.t.(sessionDiagnosticsProvider); ok {
			diagnostics := provider.sessionDiagnostics()
			s.ProtocolVersion = diagnostics.ProtocolVersion
			s.SessionState = diagnostics.State
			s.SessionIDPresent = diagnostics.SessionIDPresent
			s.ReconnectAttempts = diagnostics.ReconnectAttempts
			s.LastErrorKind = diagnostics.LastErrorKind
			s.LastError = diagnostics.LastError
		}
		for _, p := range h.prompts {
			if p.Server == c.name {
				s.Prompts++
			}
		}
		for _, r := range h.resources {
			if r.Server == c.name {
				s.Resources++
			}
		}
		out = append(out, s)
	}
	return out
}

// RecordFailure stores a failed MCP connection attempt for status UIs.
func (h *Host) RecordFailure(s Spec, err error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	tt := strings.ToLower(strings.TrimSpace(s.Type))
	if tt == "" {
		tt = "stdio"
	}
	stage, elapsed, stderr := startupFailureDetails(err)
	f := Failure{
		Name: s.Name, Transport: tt, Error: summarizeFailureError(err),
		Stage: stage, Elapsed: elapsed, Stderr: stderr,
		RequiresLaunchApproval: requiresLaunchApproval(err),
	}
	for i := range h.failures {
		if h.failures[i].Name == s.Name {
			h.failures[i] = f
			return
		}
	}
	h.failures = append(h.failures, f)
}

// RecordLaunchApprovalRequired keeps an intentionally disconnected project MCP
// visible as awaiting authorization. This is used after an explicit launch
// revocation, where no failed connection attempt exists to create the status.
func (h *Host) RecordLaunchApprovalRequired(s Spec) {
	h.RecordFailure(s, &launchApprovalError{server: s.Name})
}

// ClearFailure drops a recorded startup/connection failure for status UIs.
func (h *Host) ClearFailure(name string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.clearFailure(name)
}

// clearFailure drops the failure record for name. The caller holds h.mu (Lock) —
// it runs inside addConnected / Remove, which already mutate under the lock.
func (h *Host) clearFailure(name string) {
	kept := h.failures[:0]
	for _, f := range h.failures {
		if f.Name != name {
			kept = append(kept, f)
		}
	}
	h.failures = kept
}

// NewHost returns an empty core-v1 Host. Boot always constructs one — even
// with no plugins configured — so servers can be hot-added later via Add (the
// `/mcp add` command), keeping the controller's host pointer stable.
func NewHost() *Host { return NewHostWithProfile(HostProfileCore) }

// NewHostWithProfile returns an empty Host declaring the given profile's
// client capabilities. Immutable once set; a degraded frontend constructs
// the Host with a lower profile instead of mutating a live one.
func NewHostWithProfile(profile HostProfile) *Host {
	return &Host{profile: profile.Normalize(), appInstances: newAppInstanceRegistry()}
}

// Profile returns the host's semantic capability profile, fixed at creation.
func (h *Host) Profile() HostProfile { return h.profile.Normalize() }

func (h *Host) registerDeferredCancel(name string, cancel context.CancelFunc) uint64 {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.closed {
		cancel()
		return 0
	}
	if h.deferredCancels == nil {
		h.deferredCancels = make(map[string][]context.CancelFunc)
	}
	if h.deferredGenerations == nil {
		h.deferredGenerations = make(map[string]uint64)
	}
	generation := h.deferredGenerations[name]
	if generation == 0 {
		generation = 1
		h.deferredGenerations[name] = generation
	}
	h.deferredCancels[name] = append(h.deferredCancels[name], cancel)
	return generation
}

func (h *Host) beginDeferredSpawn() bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.closed {
		return false
	}
	h.deferredWG.Add(1)
	return true
}

func (h *Host) endDeferredSpawn() {
	h.deferredWG.Done()
}

// ErrSpawningInFlight is returned by Host.Add when another caller is already
// spawning the same server on this host. The caller should retry later.
var ErrSpawningInFlight = errors.New("server spawn already in progress")

type spawnAttempt struct {
	server string
	done   chan struct{}
	tools  []tool.Tool
	err    error
}

// ConnectionResult is the eventual result of a session-owned background MCP
// handshake. Tools are provider adapters and remain off the caller's registry
// unless the caller explicitly registers them.
type ConnectionResult struct {
	Tools []tool.Tool
	Err   error
}

// EnsureConnectedInBackground starts or joins one shared initialize +
// tools/list handshake owned by lifeCtx. The returned channel is buffered, so a
// caller may stop waiting while the server continues toward readiness. Host
// shutdown and Remove cancel the background work and wait for its goroutine.
func (h *Host) EnsureConnectedInBackground(lifeCtx context.Context, s Spec) <-chan ConnectionResult {
	result := make(chan ConnectionResult, 1)
	startupBase, cancelStartupBase := context.WithCancel(lifeCtx)
	generation := h.registerDeferredCancel(s.Name, cancelStartupBase)
	if !h.beginDeferredSpawn() {
		cancelStartupBase()
		result <- ConnectionResult{Err: fmt.Errorf("plugin host is closed")}
		return result
	}
	go func() {
		defer h.endDeferredSpawn()
		defer cancelStartupBase()
		started := time.Now()
		startupCtx, cancelStartup := context.WithTimeout(startupBase, s.startupTimeout())
		tools, err := h.EnsureConnectedWithLifecycle(lifeCtx, startupCtx, s, generation)
		cancelStartup()
		if err != nil {
			err = newStartupFailure("connect", started, "", err)
			if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrDeferredSpawnCancelled) {
				h.RecordFailure(s, err)
			}
		}
		result <- ConnectionResult{Tools: tools, Err: err}
	}()
	return result
}

// beginSpawn atomically claims the sole right to spawn the named server.
// Returns owner=true if the caller should proceed. When another caller is
// already spawning the same server, owner=false and done is closed when that
// spawn finishes.
func (h *Host) beginSpawn(key, server string) (*spawnAttempt, bool) {
	h.spawningMu.Lock()
	defer h.spawningMu.Unlock()
	if h.spawning == nil {
		h.spawning = make(map[string]*spawnAttempt)
	}
	if attempt, ok := h.spawning[key]; ok {
		return attempt, false
	}
	attempt := &spawnAttempt{server: server, done: make(chan struct{})}
	h.spawning[key] = attempt
	return attempt, true
}

// endSpawn releases the spawn claim for the named server.
func (h *Host) endSpawn(name string, tools []tool.Tool, err error) {
	h.spawningMu.Lock()
	if attempt, ok := h.spawning[name]; ok {
		attempt.tools = append([]tool.Tool(nil), tools...)
		attempt.err = err
		delete(h.spawning, name)
		close(attempt.done)
	}
	h.spawningMu.Unlock()
}

// has reports whether a server with this name is already connected.
func (h *Host) has(name string) bool {
	h.mu.RLock()
	defer h.mu.RUnlock()
	return h.hasLocked(name)
}

func (h *Host) hasLocked(name string) bool {
	for _, c := range h.clients {
		if c.name == name {
			return true
		}
	}
	return false
}

// HasClient reports whether a server with this name is already connected to the host.
func (h *Host) HasClient(name string) bool { return h.has(name) }

// HasClientForSpec reports whether the shared Host client for spec.Name was
// created from the same runtime connection identity. Server names are only a
// display/routing namespace; they are not sufficient authorization identity
// when controllers with different project configs share one Host.
func (h *Host) HasClientForSpec(spec Spec) bool {
	c := h.client(spec.Name)
	return c != nil && MCPRuntimeSpecMatches(c.spec, spec)
}

// ToolsFor returns the namespaced tool instances for an already-connected client.
// ctx bounds the tools/list call so a non-responsive server does not hang
// permanently. An error is returned when no client with that name is connected.
func (h *Host) ToolsFor(ctx context.Context, name string) ([]tool.Tool, error) {
	h.mu.RLock()
	closed := h.closed
	h.mu.RUnlock()
	if closed {
		return nil, fmt.Errorf("plugin host is closed")
	}

	// Attempt to resolve via the existing Client.
	c := h.client(name)
	if c == nil {
		return nil, fmt.Errorf("client %q not found on shared host", name)
	}
	if err := h.claimClientFromContext(ctx, c); err != nil {
		return nil, err
	}
	if tools, ok := c.cachedTools(); ok {
		return tools, nil
	}
	return c.listTools(ctx)
}

// ToolsForSpec is the identity-bound variant used by stable capability
// frontends. It refuses a same-name client from another controller, project
// identity, endpoint, or prior hot-update generation instead of treating that
// client as the current runtime's authorized server.
func (h *Host) ToolsForSpec(ctx context.Context, spec Spec) ([]tool.Tool, error) {
	h.mu.RLock()
	closed := h.closed
	h.mu.RUnlock()
	if closed {
		return nil, fmt.Errorf("plugin host is closed")
	}
	c := h.client(spec.Name)
	if c == nil {
		return nil, fmt.Errorf("client %q not found on shared host", spec.Name)
	}
	if !MCPRuntimeSpecMatches(c.spec, spec) {
		return nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration", spec.Name)
	}
	if err := h.claimClientFromContext(ctx, c); err != nil {
		return nil, err
	}
	if tools, ok := c.cachedTools(); ok {
		return tools, nil
	}
	return c.listTools(ctx)
}

// MCPRuntimeSpecMatches compares the complete host-local runtime behavior of
// two specs while deliberately excluding non-behavioral handles such as the
// stderr writer and LaunchManager pointer. Secret values are compared only in
// memory and are never serialized into diagnostics or provider-visible state.
func MCPRuntimeSpecMatches(a, b Spec) bool {
	return reflect.DeepEqual(mcpRuntimeSpecIdentityOf(a), mcpRuntimeSpecIdentityOf(b))
}

// MCPToolMatchesSpec reports whether a concrete plugin adapter or pinned lazy
// placeholder belongs to the requested runtime spec. Unknown tool
// implementations fail closed when a runtime-bound capability frontend asks.
func MCPToolMatchesSpec(t tool.Tool, spec Spec) bool {
	switch typed := t.(type) {
	case *remoteTool:
		return typed != nil && typed.client != nil && MCPRuntimeSpecMatches(typed.client.spec, spec)
	case *lazyTool:
		return typed != nil && typed.shared != nil && MCPRuntimeSpecMatches(typed.shared.spec, spec)
	default:
		return false
	}
}

type mcpRuntimeSpecIdentity struct {
	Name                    string
	Package                 string
	Type                    string
	Command                 string
	Args                    []string
	Env                     map[string]string
	URL                     string
	Headers                 map[string]string
	DefaultStartupTimeout   time.Duration
	StartupTimeout          time.Duration
	DefaultCallTimeout      time.Duration
	CallTimeout             time.Duration
	ToolTimeouts            map[string]time.Duration
	Dir                     string
	WorkspaceRoot           string
	LaunchWorkspace         string
	ConfigSource            string
	RequireLaunchApproval   bool
	LaunchArgs              []string
	LauncherIdentityArgs    []string
	LauncherLocator         string
	LauncherResolvedVersion string
	LauncherDigest          string
	ProcessMode             MCPProcessMode
	Sandbox                 sandbox.Spec
	StateDir                string
	StripRawPrefix          string
	LowPriority             bool
}

func mcpRuntimeSpecIdentityOf(s Spec) mcpRuntimeSpecIdentity {
	launchWorkspace := ""
	if s.LaunchManager != nil {
		launchWorkspace = s.LaunchManager.WorkspaceFingerprint()
	}
	return mcpRuntimeSpecIdentity{
		Name:                    strings.TrimSpace(s.Name),
		Package:                 strings.TrimSpace(s.Package),
		Type:                    canonicalMCPRuntimeTransport(s.Type),
		Command:                 s.Command,
		Args:                    nonEmptyStrings(s.Args),
		Env:                     nonEmptyStringMap(s.Env),
		URL:                     s.URL,
		Headers:                 nonEmptyStringMap(s.Headers),
		DefaultStartupTimeout:   s.DefaultStartupTimeout,
		StartupTimeout:          s.StartupTimeout,
		DefaultCallTimeout:      s.DefaultCallTimeout,
		CallTimeout:             s.CallTimeout,
		ToolTimeouts:            nonEmptyDurationMap(s.ToolTimeouts),
		Dir:                     s.Dir,
		WorkspaceRoot:           s.WorkspaceRoot,
		LaunchWorkspace:         launchWorkspace,
		ConfigSource:            strings.TrimSpace(s.ConfigSource),
		RequireLaunchApproval:   s.RequireLaunchApproval,
		LaunchArgs:              nonEmptyStrings(s.LaunchArgs),
		LauncherIdentityArgs:    nonEmptyStrings(s.LauncherIdentityArgs),
		LauncherLocator:         s.LauncherLocator,
		LauncherResolvedVersion: s.LauncherResolvedVersion,
		LauncherDigest:          s.LauncherDigest,
		ProcessMode:             s.ResolvedProcessMode(),
		Sandbox:                 canonicalMCPRuntimeSandbox(s.Sandbox),
		StateDir:                s.StateDir,
		StripRawPrefix:          s.StripRawPrefix,
		LowPriority:             s.LowPriority,
	}
}

func canonicalMCPRuntimeTransport(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "", "stdio":
		return "stdio"
	case "http", "streamable-http", "streamable_http":
		return "streamable-http"
	case "sse":
		return "sse"
	default:
		return strings.ToLower(strings.TrimSpace(raw))
	}
}

func canonicalMCPRuntimeSandbox(in sandbox.Spec) sandbox.Spec {
	in.WriteRoots = nonEmptyStrings(in.WriteRoots)
	in.ReadRoots = nonEmptyStrings(in.ReadRoots)
	in.AppContainerWriteRoots = nonEmptyStrings(in.AppContainerWriteRoots)
	in.ForbidReadRoots = nonEmptyStrings(in.ForbidReadRoots)
	return in
}

func nonEmptyStrings(in []string) []string {
	if len(in) == 0 {
		return nil
	}
	return in
}

func nonEmptyStringMap(in map[string]string) map[string]string {
	if len(in) == 0 {
		return nil
	}
	return in
}

func nonEmptyDurationMap(in map[string]time.Duration) map[string]time.Duration {
	if len(in) == 0 {
		return nil
	}
	return in
}

func (h *Host) client(name string) *Client { return h.lookupClient(name) }

// Add connects one server live: it performs the MCP handshake, discovers the
// server's tools (and prompts/resources when advertised), appends it to the
// host, and returns its namespaced tools for the caller to register. ctx bounds a
// stdio child's lifetime, so pass the session-scoped context — not a per-turn one
// — or the subprocess dies when that turn ends. Errors if the name is taken.
func (h *Host) Add(ctx context.Context, s Spec) ([]tool.Tool, error) {
	return h.addWithLifecycle(ctx, ctx, s, 0)
}

// EnsureConnected returns tools for an already-connected server, or starts the
// shared single-flight handshake and waits for it. Concurrent callers for the
// same server share one initialize/tools-list; cancelling a waiter only cancels
// that wait and never kills a process still used by other runtimes.
func (h *Host) EnsureConnected(ctx context.Context, s Spec) ([]tool.Tool, error) {
	return h.EnsureConnectedWithLifecycle(ctx, ctx, s, 0)
}

// EnsureConnectedWithLifecycle is EnsureConnected with separate subprocess
// lifetime (lifeCtx) and startup/call (callCtx) contexts, plus an optional
// deferred generation for lazy registration.
func (h *Host) EnsureConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
	if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) {
		return nil, ErrDeferredSpawnCancelled
	}
	if tools, err := h.ToolsFor(callCtx, s.Name); err == nil {
		return tools, nil
	}
	tools, err := h.addWithLifecycle(lifeCtx, callCtx, s, deferredGeneration)
	if IsServerAlreadyConnected(err) {
		return h.ToolsFor(callCtx, s.Name)
	}
	return tools, err
}

// AddWithLifecycle connects one server live, allowing caller to specify separate
// contexts for the subprocess lifecycle (lifeCtx, session-scoped) and the startup
// handshake/list calls (callCtx, turn-scoped/timeout-bound).
func (h *Host) AddWithLifecycle(lifeCtx, callCtx context.Context, s Spec) ([]tool.Tool, error) {
	return h.addWithLifecycle(lifeCtx, callCtx, s, 0)
}

func (h *Host) addWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
	if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) {
		return nil, ErrDeferredSpawnCancelled
	}
	if h.has(s.Name) {
		return nil, serverAlreadyConnectedError(s.Name)
	}
	spawnKey := s.Name
	if deferredGeneration != 0 {
		spawnKey = fmt.Sprintf("%s#%d", s.Name, deferredGeneration)
	}
	attempt, owner := h.beginSpawn(spawnKey, s.Name)
	if !owner {
		select {
		case <-attempt.done:
			if attempt.err != nil {
				return nil, attempt.err
			}
			return append([]tool.Tool(nil), attempt.tools...), nil
		case <-callCtx.Done():
			return nil, callCtx.Err()
		case <-lifeCtx.Done():
			return nil, lifeCtx.Err()
		}
	}
	var tools []tool.Tool
	var err error
	defer func() { h.endSpawn(spawnKey, tools, err) }()
	// Double-check after acquiring the spawn token: another caller may have
	// connected the server between our h.has check and beginSpawn.
	if h.has(s.Name) {
		err = serverAlreadyConnectedError(s.Name)
		return nil, err
	}
	tools, err = h.addConnectedWithLifecycle(lifeCtx, callCtx, s, deferredGeneration)
	return tools, err
}

func (h *Host) addConnected(ctx context.Context, s Spec) ([]tool.Tool, error) {
	return h.addConnectedWithLifecycle(ctx, ctx, s, 0)
}

func (h *Host) addConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
	startupStarted := time.Now()
	h.mu.RLock()
	if h.closed {
		h.mu.RUnlock()
		return nil, fmt.Errorf("plugin host is closed")
	}
	h.mu.RUnlock()

	c, err := start(lifeCtx, callCtx, s, h.profile)
	if err != nil {
		return nil, err
	}
	h.bindToolListChanges(c)
	ts, err := c.listTools(callCtx)
	if err != nil {
		c.close()
		err = newStartupFailure("tools/list", startupStarted, c.startupStderr(), err)
		return nil, fmt.Errorf("list tools: %w", err)
	}
	h.mu.Lock()
	if h.closed {
		h.mu.Unlock()
		c.close()
		return nil, fmt.Errorf("plugin host is closed")
	}
	if deferredGeneration != 0 && h.deferredGenerations[s.Name] != deferredGeneration {
		h.mu.Unlock()
		c.close()
		return nil, ErrDeferredSpawnCancelled
	}
	if h.hasLocked(s.Name) {
		h.mu.Unlock()
		c.close()
		return nil, serverAlreadyConnectedError(s.Name)
	}
	// Attribute ownership from lifeCtx so LazyToolset background kicks and
	// boot.Build share the same RegistrationScope token. Sibling hot-adds
	// without a scope are never journaled to a concurrent build.
	if err := h.noteClientFromContext(lifeCtx, c); err != nil {
		h.mu.Unlock()
		c.close()
		return nil, err
	}
	h.clearFailure(s.Name)
	h.mu.Unlock()
	if cached, ok := c.cachedTools(); ok {
		ts = cached
	}
	// Prompts and resources stream in on the long lifeCtx the caller passed (Host.Add
	// uses the session-scoped PluginCtx, not a per-turn ctx), so the slow list
	// calls cannot starve a /mcp add of its return value. nil sink keeps hot-add
	// quiet — the chat UI re-queries Host.Prompts()/Resources() on demand.
	if c.capabilities.prompts {
		h.goSurface(func() { h.fetchPrompts(lifeCtx, c, nil) })
	}
	if c.capabilities.resources {
		h.goSurface(func() { h.fetchResources(lifeCtx, c, nil) })
	}
	return ts, nil
}

// Remove disconnects the named server and drops its prompts/resources, returning
// the namespaced tool-name prefix ("mcp__<server>__") the caller unregisters from
// the tool registry, and whether the server was connected.
func (h *Host) Remove(name string) (toolPrefix string, found bool) {
	h.mu.Lock()
	cancels := append([]context.CancelFunc(nil), h.deferredCancels[name]...)
	delete(h.deferredCancels, name)
	if h.deferredGenerations == nil {
		h.deferredGenerations = make(map[string]uint64)
	}
	h.deferredGenerations[name]++
	if h.deferredGenerations[name] == 0 {
		h.deferredGenerations[name] = 1
	}
	idx := -1
	for i, c := range h.clients {
		if c.name == name {
			idx = i
			break
		}
	}
	if idx < 0 {
		h.mu.Unlock()
		for _, cancel := range cancels {
			cancel()
		}
		if len(cancels) == 0 {
			return "", false
		}
		return ToolPrefix(name), true
	}
	removed := h.removeClientAtLocked(idx)
	if h.appInstances != nil {
		h.appInstances.ReleaseServer(name)
	}
	h.mu.Unlock()

	for _, cancel := range cancels {
		cancel()
	}
	removed.close() // kills the subprocess: outside the lock

	return "mcp__" + normalizeName(name) + "__", true
}

func (h *Host) deferredGenerationCurrent(name string, generation uint64) bool {
	h.mu.RLock()
	defer h.mu.RUnlock()
	return !h.closed && generation != 0 && h.deferredGenerations[name] == generation
}

// ErrDeferredSpawnCancelled marks a lazy generation invalidated by remove or
// host shutdown before it could publish a client.
var ErrDeferredSpawnCancelled = errors.New("deferred MCP spawn cancelled")

// start opens the transport on lifeCtx (whose cancellation later closes the
// subprocess) and uses callCtx for the initialize round-trip (whose cancellation
// only bounds startup RPCs). Splitting the two lets a per-plugin timeout cap
// handshake latency without making the timeout context own a successfully
// registered stdio server; the child also has to outlive phase A so phase B
// (prompts + resources) can still call it later. Callers that don't care pass
// the same ctx for both.
func start(lifeCtx, callCtx context.Context, s Spec, profile HostProfile) (*Client, error) {
	started := time.Now()
	var err error
	s, err = applyStoredLauncherLock(s)
	if err != nil {
		return nil, newStartupFailure("launch", started, "", err)
	}
	s, err = resolveProjectLaunchAuthorization(callCtx, s)
	if err != nil {
		return nil, newStartupFailure("authorization", started, "", err)
	}
	t, err := newTransport(lifeCtx, s, profile)
	if err != nil {
		return nil, newStartupFailure("launch", started, "", err)
	}
	tt := strings.ToLower(strings.TrimSpace(s.Type))
	if tt == "" {
		tt = "stdio"
	}
	refreshCtx := lifeCtx
	if refreshCtx == nil {
		refreshCtx = context.Background()
	}
	refreshCtx, cancelRefresh := context.WithCancel(refreshCtx)
	c := &Client{
		name:      s.Name,
		t:         t,
		spec:      s,
		profile:   profile.Normalize(),
		transport: tt,
		refresh: toolListRefreshState{
			ctx:    refreshCtx,
			cancel: cancelRefresh,
		},
	}
	if err := c.initialize(callCtx); err != nil {
		c.close()
		err = newStartupFailure("initialize", started, c.startupStderr(), err)
		return nil, err
	}
	return c, nil
}

// resolveProjectLaunchAuthorization deliberately skips identity resolution for
// installed and host-session servers. Their explicit installation is already
// the authorization decision; only repository-declared servers need an exact
// executable or endpoint digest before startup.
func resolveProjectLaunchAuthorization(ctx context.Context, s Spec) (Spec, error) {
	if !s.RequireLaunchApproval {
		return s, nil
	}
	identityDigest, err := projectLaunchIdentityDigest(ctx, s)
	if err != nil {
		return s, err
	}
	return applyEstablishedLaunchGrant(s, identityDigest)
}

func applyEstablishedLaunchGrant(s Spec, identityDigest string) (Spec, error) {
	if !s.RequireLaunchApproval {
		return s, nil
	}
	if s.LaunchManager == nil {
		return s, fmt.Errorf("MCP launch authorization store is unavailable")
	}
	authorized, changed, err := s.LaunchManager.LaunchAuthorized(s.Name, launchConfigSource(s), identityDigest)
	if err != nil {
		return s, err
	}
	if !authorized {
		return s, &launchApprovalError{server: s.Name, changed: changed}
	}
	// A matching exact-identity launch grant is the user's authorization for
	// this project server. Calls proceed like an explicit install, while global
	// deny rules and execution safety boundaries remain authoritative.
	s.Authorized = true
	return s, nil
}

// ResolveStoredAuthorization applies an existing exact project grant without
// starting a process or opening a network connection. Cached lazy/on-demand
// tools use it before strict read-only filtering so every execution path sees
// the same server-level authorization. Errors fail closed by returning the
// original unauthorized Spec; a parent connection surfaces the detailed error.
func ResolveStoredAuthorization(ctx context.Context, s Spec) Spec {
	if !s.RequireLaunchApproval {
		return s
	}
	locked, err := applyStoredLauncherLock(s)
	if err != nil {
		return s
	}
	authorized, err := resolveProjectLaunchAuthorization(ctx, locked)
	if err != nil {
		return s
	}
	return authorized
}

// ServerAuthorized is the single MCP authorization source. Tools do not carry
// an independent trust bit: installation or an exact project launch grant
// authorizes the server, while read-only/destructive classification remains a
// live per-tool safety fact.
func (s Spec) ServerAuthorized() bool {
	return s.Authorized
}

// newTransport builds the transport for a spec's declared type. Empty / unknown
// defaults to stdio.
func newTransport(ctx context.Context, s Spec, profile HostProfile) (transport, error) {
	return newSDKSessionTransport(ctx, s, profile)
}

func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
	params, unregisterProgress := c.withProgress(ctx, method, params)
	defer unregisterProgress()

	callCtx, cancel, timeout := c.contextWithCallTimeout(ctx, method, params)
	if cancel != nil {
		defer cancel()
	}

	started := time.Now()
	res, err := c.callTransport(callCtx, method, params)
	c.observeProtocol(method, res, time.Since(started), err)
	if timeout > 0 && errors.Is(err, context.DeadlineExceeded) && callCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil {
		slog.Warn("plugin: MCP call timed out",
			"server", c.name, "method", method, "tool", rawToolNameFromCallParams(params), "timeout", timeout)
		return nil, c.timeoutError(method, params, timeout)
	}
	return res, err
}

func (c *Client) withProgress(ctx context.Context, method string, params any) (any, func()) {
	if method != "tools/call" {
		return params, func() {}
	}
	sink, ok := tool.ProgressFrom(ctx)
	if !ok {
		return params, func() {}
	}
	router, ok := c.t.(progressTransport)
	if !ok {
		return params, func() {}
	}
	callParams, ok := params.(map[string]any)
	if !ok {
		return params, func() {}
	}

	token := fmt.Sprintf("reasonix-%d", c.progressID.Add(1))
	copyParams := make(map[string]any, len(callParams))
	maps.Copy(copyParams, callParams)
	meta := map[string]any{}
	if existing, ok := callParams["_meta"].(map[string]any); ok {
		maps.Copy(meta, existing)
	}
	meta["progressToken"] = token
	copyParams["_meta"] = meta
	unregister := router.registerProgress(token, sink)
	return copyParams, unregister
}

func (c *Client) callTransport(ctx context.Context, method string, params any) (json.RawMessage, error) {
	return c.t.call(ctx, method, params)
}

func (c *Client) contextWithCallTimeout(ctx context.Context, method string, params any) (context.Context, context.CancelFunc, time.Duration) {
	if _, ok := ctx.Deadline(); ok {
		return ctx, nil, 0
	}
	timeout := c.callTimeout(method, params)
	if timeout <= 0 {
		timeout = defaultCallTimeout
	}
	callCtx, cancel := context.WithTimeout(ctx, timeout)
	return callCtx, cancel, timeout
}

func (c *Client) callTimeout(method string, params any) time.Duration {
	if method == "tools/call" {
		if raw := rawToolNameFromCallParams(params); raw != "" {
			if timeout := c.spec.ToolTimeouts[raw]; timeout > 0 {
				return timeout
			}
		}
	}
	if c.spec.CallTimeout > 0 {
		return c.spec.CallTimeout
	}
	if c.spec.DefaultCallTimeout > 0 {
		return c.spec.DefaultCallTimeout
	}
	return defaultCallTimeout
}

func rawToolNameFromCallParams(params any) string {
	m, ok := params.(map[string]any)
	if !ok {
		return ""
	}
	name, _ := m["name"].(string)
	return name
}

func (c *Client) timeoutError(method string, params any, timeout time.Duration) error {
	if method == "tools/call" {
		if raw := rawToolNameFromCallParams(params); raw != "" {
			return fmt.Errorf("MCP tool %q timed out after %s; execution may have completed, so it was not retried automatically; increase tool_timeout_seconds or call_timeout_seconds to allow longer runs: %w",
				c.name+"."+raw, formatTimeout(timeout), context.DeadlineExceeded)
		}
	}
	return fmt.Errorf("MCP method %q on server %q timed out after %s; increase mcp_call_timeout_seconds or call_timeout_seconds to allow longer runs: %w",
		method, c.name, formatTimeout(timeout), context.DeadlineExceeded)
}

func formatTimeout(timeout time.Duration) string {
	if timeout > 0 && timeout%time.Second == 0 {
		return fmt.Sprintf("%ds", int(timeout/time.Second))
	}
	return timeout.String()
}

// toolName builds Reasonix's canonical model-visible name
// "mcp__<server>__<tool>". The registry separately resolves unique portable
// and Claude plugin-qualified references without exposing duplicate schemas.
func toolName(server, raw string) string {
	return ToolPrefix(server) + normalizeName(raw)
}

// ToolPrefix is the model-visible namespace prefix for every tool from server.
func ToolPrefix(server string) string {
	return "mcp__" + normalizeName(server) + "__"
}

// MCPConnectPermissionName is the canonical permission and hook identity for
// starting server on demand. It is intentionally outside the mcp__ tool
// namespace: permission rules match tool names exactly, so a connect must have
// its own non-colliding name instead of pretending a tool-prefix is a glob.
func MCPConnectPermissionName(server string) string {
	return "mcp_connect__" + normalizeName(server)
}

// ModelToolName is the canonical model-visible name for server's raw tool —
// including the collision-hash suffix normalizeName appends when the raw name
// needed sanitising. Every permission/hook/audit surface that names an MCP
// tool must build the name through this function; a second normalization that
// skips the hash would let deny/ask rules written for the executed name miss.
func ModelToolName(server, raw string) string {
	return toolName(server, raw)
}

var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)

func normalizeName(s string) string {
	raw := s
	s = strings.Trim(invalidNameChars.ReplaceAllString(s, "_"), "_")
	if s == "" {
		s = "unnamed"
	}
	if s != raw {
		s += "_" + shortNameHash(raw)
	}
	return s
}

func shortNameHash(s string) string {
	h := fnv.New32a()
	_, _ = h.Write([]byte(s))
	return fmt.Sprintf("%08x", h.Sum32())[:6]
}

func summarizeFailureError(err error) string {
	msg := strings.Join(strings.Fields(secrets.RedactCredentials(err.Error())), " ")
	const max = 500
	if len(msg) > max {
		msg = msg[:max] + "..."
	}
	return msg
}

// remote tool adapter

type remoteTool struct {
	client           *Client
	name             string // namespaced "mcp__<server>__<tool>"
	rawName          string // original name for tools/call
	visibleName      string // raw name after configured prefix stripping
	desc             string
	schema           json.RawMessage
	outputSchema     json.RawMessage
	declaredReadOnly bool // server hint, independent of server authorization
	readOnly         bool // effective reader classification for this live snapshot
	// destructive is the MCP destructiveHint. It takes precedence over a
	// conflicting readOnlyHint in Plan and strict read-only execution.
	destructive bool
	generation  uint64
	// Apps metadata: whether the App channel may call this tool and the ui
	// resource an App renders results with.
	visibility    []string
	appCallable   bool
	uiResourceURI string
	uiCSP         map[string][]string
}

func (t *remoteTool) Name() string        { return t.name }
func (t *remoteTool) Description() string { return t.desc }
func (t *remoteTool) MCPServerName() string {
	if t.client == nil {
		return ""
	}
	return t.client.name
}
func (t *remoteTool) MCPRawToolName() string     { return t.rawName }
func (t *remoteTool) MCPVisibleToolName() string { return t.visibleName }
func (t *remoteTool) MCPPackageName() string {
	if t.client == nil {
		return ""
	}
	return t.client.spec.Package
}

// AppCallable reports whether an App instance may invoke this tool.
func (t *remoteTool) AppCallable() bool { return t.appCallable }

// UIResourceURI returns the MCP Apps _meta.ui.resourceUri (empty = none).
func (t *remoteTool) UIResourceURI() string { return t.uiResourceURI }

// UICSP returns the resource's declared CSP directives (nil = default deny).
func (t *remoteTool) UICSP() map[string][]string { return t.uiCSP }

func (t *remoteTool) MCPServerAuthorized() bool {
	return t.client != nil && t.client.spec.ServerAuthorized()
}

// ReadOnly reflects MCP readOnlyHint plus backward-compatible Spec overrides.
// It defaults to false, so opaque tools remain write-capable unless the server
// or local configuration explicitly classifies them as read-only.
func (t *remoteTool) securitySnapshot() (declaredReadOnly, readOnly, destructive bool) {
	if t.client == nil {
		return t.declaredReadOnly, t.readOnly, t.destructive
	}
	t.client.toolsMu.RLock()
	defer t.client.toolsMu.RUnlock()
	return t.declaredReadOnly, t.readOnly, t.destructive
}

func (t *remoteTool) ReadOnly() bool {
	_, readOnly, _ := t.securitySnapshot()
	return readOnly
}

func (t *remoteTool) MCPDestructiveHint() bool {
	_, _, destructive := t.securitySnapshot()
	return destructive
}

func (t *remoteTool) Schema() json.RawMessage {
	if len(t.schema) == 0 {
		return json.RawMessage(`{"type":"object"}`)
	}
	return canonicalizeSchema(t.schema)
}

func (t *remoteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
	text, _, err := t.ExecuteWithImages(ctx, args)
	return text, err
}

// ExecuteWithImages implements tool.ImageTool: MCP results may carry image
// content items, which callers with a structural image channel (the agent)
// forward to vision models instead of relying on the text placeholders alone.
func (t *remoteTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
	res, err := t.callRaw(ctx, args)
	if err != nil {
		return "", nil, err
	}
	stampMCPAppResult(ctx, t, res)
	return parseToolResultWithSchema(res, t.outputSchema)
}

// ExecuteForApp returns the complete standard CallToolResult to an MCP App.
// The text and isError flag are separate host-local projections used for hooks
// and transcript events; an MCP isError result remains a successful bridge
// response so the App receives its structured fields and metadata.
func (t *remoteTool) ExecuteForApp(ctx context.Context, args json.RawMessage) (json.RawMessage, string, bool, error) {
	res, err := t.callRaw(ctx, args)
	if err != nil {
		return nil, "", false, err
	}
	res, err = tool.ValidateMCPAppCallResult(res)
	if err != nil {
		return nil, "", false, err
	}
	var status struct {
		IsError bool `json:"isError"`
	}
	if err := json.Unmarshal(res, &status); err != nil {
		return nil, "", false, fmt.Errorf("decode MCP App tool result: %w", err)
	}
	text, _, parseErr := parseToolResultForApp(res)
	if parseErr != nil && !status.IsError {
		return nil, "", false, parseErr
	}
	return res, text, status.IsError, nil
}

func (t *remoteTool) callRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
	if t.client == nil {
		return nil, errors.New("MCP tool client is unavailable")
	}
	t.client.toolDispatchMu.RLock()
	defer t.client.toolDispatchMu.RUnlock()
	if t.client.closed.Load() {
		return nil, fmt.Errorf("MCP server %q is closed", t.client.name)
	}
	if t.client.toolCatalogStale() {
		t.client.ensureToolsRefresh()
		return nil, fmt.Errorf("MCP server %q changed its tool catalog and the refresh is still pending or failed; retry so Reasonix can apply the current schema and safety metadata", t.client.name)
	}
	if t.generation == 0 || t.generation != t.client.catalogGeneration {
		return nil, fmt.Errorf("MCP server %q changed tool %q after this call was authorized; retry so Reasonix can apply the current schema and safety metadata", t.client.name, t.rawName)
	}
	var argMap map[string]any
	if len(args) > 0 {
		if err := json.Unmarshal(args, &argMap); err != nil {
			return nil, fmt.Errorf("invalid args: %w", err)
		}
	}
	readOnly, destructive := t.readOnly, t.destructive
	if tool.HasReaderExecutionIntent(ctx) {
		// Final, linearizable check for a reader-authorized call: the snapshot
		// above and every catalog publication serialize on toolDispatchMu. A call
		// approved as a non-destructive reader must never execute after
		// authorization or safety metadata changed — state drift here returns an
		// actionable error instead of dispatching.
		if !t.MCPServerAuthorized() || !readOnly || destructive {
			return nil, fmt.Errorf("MCP server %q changed the authorization or security metadata for tool %q; the call was blocked before dispatch — refresh the server from a parent session before retrying", t.client.name, t.rawName)
		}
	}
	if tool.HasNonDestructiveMCPExecutionIntent(ctx) {
		// Planner lane: authorized + non-destructive only. Missing readOnlyHint
		// is intentional and does not block; destructive promotion or lost
		// authorization must produce zero tools/call.
		if !t.MCPServerAuthorized() || destructive {
			return nil, fmt.Errorf("MCP server %q changed the authorization or destructive classification for tool %q; the call was blocked before dispatch — retry so Reasonix can re-apply the current Planner MCP safety boundary", t.client.name, t.rawName)
		}
	}
	tool.ObserveRemoteDispatch(ctx)
	res, err := t.client.call(ctx, "tools/call", map[string]any{
		"name":      t.rawName,
		"arguments": argMap,
	})
	if err != nil {
		return nil, err
	}
	return res, nil
}
