mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-09 01:59:43 -10:00
1659 lines
59 KiB
Go
1659 lines
59 KiB
Go
// 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 wire protocol is JSON-RPC 2.0 in every
|
|
// case; only the transport differs (stdio subprocess, Streamable HTTP, or the
|
|
// legacy HTTP+SSE). A transport interface hides that difference so the MCP-level
|
|
// logic — handshake, tools/list, tools/call — is written once.
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"reasonix/internal/base/secrets"
|
|
"reasonix/internal/contract/event"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/contract/tool"
|
|
"reasonix/internal/ext/mcplaunch"
|
|
"reasonix/internal/safety/sandbox"
|
|
)
|
|
|
|
// protocolVersion is the MCP revision Reasonix advertises during initialize.
|
|
const protocolVersion = "2025-11-25"
|
|
|
|
// 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
|
|
// DisabledTools excludes exact server-local names before schema registration.
|
|
DisabledTools []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
|
|
// WritableRoots are the folders the session lets tools write, the workspace
|
|
// among them. A local stdio server is told the others per call; runtime-only.
|
|
WritableRoots []string
|
|
// Stderr receives byte-count summaries because raw child logs can carry credentials.
|
|
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
|
|
// LaunchCheck, when set, runs before every process start or connection and
|
|
// refuses one whose project declaration no longer reads as it did when the
|
|
// spec was built; a lazy start can come long after the decision was read.
|
|
LaunchCheck func() error
|
|
// 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
|
|
ProcessStateDir string
|
|
OAuthHTTPClient *http.Client
|
|
// OAuthAllowMissingPKCEMetadata accepts an authorization server that does
|
|
// not list code_challenge_methods_supported; see ErrOAuthPKCEUnadvertised.
|
|
OAuthAllowMissingPKCEMetadata bool
|
|
// OAuthScopeUpgrade makes AuthorizeHTTPMCP ask for the scopes the server
|
|
// challenged for, on top of what was granted, instead of starting afresh.
|
|
OAuthScopeUpgrade bool
|
|
// 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 (correlating by id internally); notify sends a
|
|
// fire-and-forget notification; 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)
|
|
notify(ctx context.Context, method string, params any) 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
|
|
compositionGuards map[string]*compositionToolGuard
|
|
|
|
// statusMu guards statusSink alone: it is written once at assembly and read
|
|
// from whichever goroutine a background connection finished on.
|
|
statusMu sync.RWMutex
|
|
statusSink event.Sink
|
|
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.CancelCauseFunc
|
|
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
|
|
|
|
// toolListChanges are the subscribers to catalog changes a connected server
|
|
// announces; guarded by mu like the client list it publishes from.
|
|
toolListChanges toolListWatchers
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 the SaveCachedSchema side effect. Use for
|
|
// read-only live probes (capability diagnostics) that must not write
|
|
// 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()
|
|
|
|
// 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)
|
|
if err != nil {
|
|
cancelStartup()
|
|
ch <- result{idx: idx, spec: spec, err: fmt.Errorf("start plugin %q: %w", spec.Name, err)}
|
|
return
|
|
}
|
|
|
|
ts, err := c.listTools(callCtx)
|
|
if err != nil {
|
|
cancelStartup()
|
|
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
|
|
}
|
|
h.announceHeld(c)
|
|
|
|
// Persist for next launch on the side: a slow cache write must not
|
|
// delay tools coming online, and a failed one only costs a handshake.
|
|
cancelStartup()
|
|
if !p.SkipPersistence {
|
|
h.bgWrites.Go(func() { c.saveHandshakeSchema(spec, 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
|
|
}
|
|
if err := h.noteClientLocked(r.client, nil); err != nil {
|
|
r.client.close()
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
h.bindToolListChanges(r.client)
|
|
tools = append(tools, r.tools...)
|
|
// 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.CancelCauseFunc
|
|
for _, serverCancels := range h.deferredCancels {
|
|
cancels = append(cancels, serverCancels...)
|
|
}
|
|
h.deferredCancels = nil
|
|
h.mu.Unlock()
|
|
|
|
for _, cancel := range cancels {
|
|
cancel(ErrHostClosed)
|
|
}
|
|
h.deferredWG.Wait()
|
|
|
|
h.mu.Lock()
|
|
clients := append([]*Client(nil), h.clients...)
|
|
proxies := h.proxies
|
|
h.proxies = nil
|
|
h.clients = nil
|
|
h.mu.Unlock()
|
|
closeServerProxies(proxies)
|
|
for _, c := range clients {
|
|
if c != nil && c.t != nil {
|
|
c.close()
|
|
}
|
|
}
|
|
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()
|
|
})
|
|
}
|
|
|
|
// 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.hasPrompts {
|
|
go h.fetchPrompts(ctx, c, sink)
|
|
}
|
|
if c.hasResources {
|
|
go h.fetchResources(ctx, c, sink)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) {
|
|
aux, err := c.auxiliaryClient(ctx)
|
|
if err != nil {
|
|
slog.Warn("plugin: start auxiliary prompt client failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
|
return
|
|
}
|
|
defer aux.close()
|
|
|
|
ps, err := aux.listPrompts(ctx)
|
|
if err != nil {
|
|
slog.Warn("plugin: listPrompts failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
|
return
|
|
}
|
|
for i := range ps {
|
|
ps[i].client = c
|
|
}
|
|
h.mu.Lock()
|
|
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) {
|
|
aux, err := c.auxiliaryClient(ctx)
|
|
if err != nil {
|
|
slog.Warn("plugin: start auxiliary resource client failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
|
return
|
|
}
|
|
defer aux.close()
|
|
|
|
rs, err := aux.listResources(ctx)
|
|
if err != nil {
|
|
slog.Warn("plugin: listResources failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
|
return
|
|
}
|
|
h.mu.Lock()
|
|
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)),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Client is one MCP server connection: a name plus the transport carrying its
|
|
// JSON-RPC. The MCP-level methods (initialize, listTools, …) are transport-
|
|
// agnostic — they go through t.
|
|
type Client struct {
|
|
name string
|
|
instanceID uint64 // Host-local identity for RemoveIfInstance rollback
|
|
t transport
|
|
spec Spec
|
|
|
|
// 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
|
|
|
|
// modern is set once, at connect, when the server speaks a handshake-free
|
|
// revision; every request then carries its own _meta.
|
|
modern modernSession
|
|
// tasks drives results the server answers as a task instead of at once.
|
|
tasks taskState
|
|
|
|
// Capabilities advertised by the server at initialize. prompts/list and
|
|
// resources/list are only called when advertised, so we never provoke a
|
|
// "method not found" on a tools-only server.
|
|
hasTools bool
|
|
hasPrompts bool
|
|
hasResources bool
|
|
// instructions is the server's own account of itself, offered once at
|
|
// initialize. Written during the handshake, before the client is published.
|
|
instructions string
|
|
|
|
transport string // declared transport type, for /mcp status ("stdio"/"http")
|
|
// owner is the host that published this client, which announces on its behalf.
|
|
owner atomic.Pointer[Host]
|
|
// scopeAnnounced is the scope request the user was last told about.
|
|
scopeAnnounced atomic.Value
|
|
|
|
// 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
|
|
toolsMu sync.Mutex
|
|
tools []ToolInfo
|
|
|
|
// toolAdapters caches the model-visible remote tool adapters produced by
|
|
// the first successful tools/list call. Shared hosts reuse Client instances
|
|
// across controllers, so subsequent ToolsFor calls must not re-query slow
|
|
// MCP servers just to rebuild identical schemas.
|
|
toolsListed bool
|
|
toolAdapters []tool.Tool
|
|
held []HeldTool
|
|
followed []HeldTool
|
|
// toolsFingerprint covers the published catalog, so a re-read after a
|
|
// list_changed notice can tell a real change from the same tools again.
|
|
toolsFingerprint [sha256.Size]byte
|
|
progressID atomic.Uint64
|
|
|
|
// refresh is this connection's catalog freshness: see tool_list_changed.go.
|
|
refresh toolRefresh
|
|
}
|
|
|
|
// auxiliaryClient opens a second connection so a background listing cannot
|
|
// queue behind a tool call: many servers answer one request at a time. ctx owns
|
|
// the child and the caller closes it — bounding its life by the handshake
|
|
// budget instead killed servers still importing, so the spawn never reached
|
|
// their entry point and the next call read EOF.
|
|
func (c *Client) auxiliaryClient(ctx context.Context) (*Client, error) {
|
|
callCtx, cancel := context.WithTimeout(ctx, c.spec.ResolvedStartupTimeout())
|
|
defer cancel()
|
|
return start(ctx, callCtx, c.spec)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 Host. Boot always constructs one — even with no
|
|
// plugins configured — so servers can be hot-added later via Add (the `/mcp add`
|
|
// command), which keeps the controller's host pointer stable for the session.
|
|
func NewHost() *Host { return &Host{} }
|
|
|
|
// 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.WithCancelCause(lifeCtx)
|
|
generation := h.registerDeferredCancel(s.Name, cancelStartupBase)
|
|
if !h.beginDeferredSpawn() {
|
|
cancelStartupBase(ErrHostClosed)
|
|
result <- ConnectionResult{Err: fmt.Errorf("plugin host is closed")}
|
|
return result
|
|
}
|
|
go func() {
|
|
defer h.endDeferredSpawn()
|
|
// The handshake is over either way; nothing downstream reads this cause.
|
|
defer cancelStartupBase(context.Canceled)
|
|
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, 0, err)
|
|
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrDeferredSpawnCancelled) {
|
|
h.RecordFailure(s, err)
|
|
}
|
|
}
|
|
result <- ConnectionResult{Tools: tools, Err: err}
|
|
}()
|
|
return result
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
DisabledTools []string
|
|
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
|
|
ProcessStateDir 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),
|
|
DisabledTools: disabledToolNames(s.DisabledTools),
|
|
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,
|
|
ProcessStateDir: s.ProcessStateDir,
|
|
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.ToolsForSpec(callCtx, s); err == nil {
|
|
return tools, nil
|
|
}
|
|
_, err := h.addWithLifecycle(lifeCtx, callCtx, s, deferredGeneration)
|
|
if IsServerAlreadyConnected(err) {
|
|
return h.ToolsForSpec(callCtx, s)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return h.ToolsForSpec(callCtx, s)
|
|
}
|
|
|
|
// 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 h.ToolsForSpec(callCtx, s)
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.attachCompositionGuard(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.announceHeld(c)
|
|
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)
|
|
// A server connected by hand leaves the same cache a started one does, so
|
|
// the next session registers it without a handshake. Added while h.closed is
|
|
// still false under h.mu, so Close, which sets it first, drains the write.
|
|
if deferredGeneration == 0 {
|
|
h.bgWrites.Go(func() { c.saveHandshakeSchema(s, ts) })
|
|
}
|
|
h.mu.Unlock()
|
|
h.bindToolListChanges(c)
|
|
// The status changed here, not when prompts finish arriving: a tools-only
|
|
// server has neither, so announcing from those paths leaves it showing a
|
|
// startup failure forever while the agent uses it perfectly well.
|
|
h.announce("%s: connected", s.Name)
|
|
// 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.hasPrompts {
|
|
go h.fetchPrompts(lifeCtx, c, nil)
|
|
}
|
|
if c.hasResources {
|
|
go 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.CancelCauseFunc(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(ErrServerRemoved)
|
|
}
|
|
if len(cancels) == 0 {
|
|
return "", false
|
|
}
|
|
return ToolPrefix(name), true
|
|
}
|
|
removed := h.removeClientAtLocked(idx)
|
|
h.mu.Unlock()
|
|
|
|
for _, cancel := range cancels {
|
|
cancel(ErrServerRemoved)
|
|
}
|
|
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")
|
|
|
|
// Why a lazily-started server's context ended. The host is the only one that
|
|
// knows: past this point all a caller sees is "context canceled", which names
|
|
// the mechanism and not one of the two things it could mean — and those two
|
|
// have different things to do next.
|
|
var (
|
|
ErrHostClosed error = hostDiagnosticCause("the MCP host shut down (session ended or runtime rebuilt)")
|
|
ErrServerRemoved error = hostDiagnosticCause("this MCP server was removed, disabled, or reconnected")
|
|
)
|
|
|
|
// 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) (*Client, error) {
|
|
started := time.Now()
|
|
var err error
|
|
s, err = applyStoredLauncherLock(s)
|
|
if err != nil {
|
|
return nil, newStartupFailure("launch", started, 0, err)
|
|
}
|
|
s, err = resolveProjectLaunchAuthorization(callCtx, s)
|
|
if err == nil {
|
|
err = checkDeclaredLaunch(s)
|
|
}
|
|
if err != nil {
|
|
return nil, newStartupFailure("authorization", started, 0, err)
|
|
}
|
|
t, err := newTransport(lifeCtx, s)
|
|
if err != nil {
|
|
// Reported in 0-4ms with no stderr, a bare "context canceled" reads like
|
|
// the server failed to launch. Which of the two cancelled it is
|
|
// something only the host knows, and they are different things to fix.
|
|
if errors.Is(err, context.Canceled) {
|
|
if cause := context.Cause(lifeCtx); cause != nil && !errors.Is(cause, context.Canceled) {
|
|
err = cause
|
|
}
|
|
}
|
|
return nil, newStartupFailure("launch", started, 0, err)
|
|
}
|
|
tt := strings.ToLower(strings.TrimSpace(s.Type))
|
|
if tt == "" {
|
|
tt = "stdio"
|
|
}
|
|
c := &Client{name: s.Name, spec: s, transport: tt}
|
|
c.setRefreshLifetime(lifeCtx)
|
|
c.t = newReconnectingTransport(lifeCtx, t, s.ResolvedStartupTimeout(), c.redial,
|
|
c.handshakeOn)
|
|
if err := c.connect(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) (transport, error) {
|
|
switch strings.ToLower(strings.TrimSpace(s.Type)) {
|
|
case "", "stdio":
|
|
t, err := newStdioTransport(ctx, s)
|
|
return transportOrNil(t, err)
|
|
case "http", "streamable-http", "streamable_http":
|
|
t, err := newHTTPTransport(s)
|
|
return transportOrNil(t, err)
|
|
case "sse":
|
|
t, err := newSSETransport(ctx, s)
|
|
return transportOrNil(t, err)
|
|
default:
|
|
return nil, fmt.Errorf("unknown transport type %q (want stdio|http|sse)", s.Type)
|
|
}
|
|
}
|
|
|
|
// transportOrNil hands back a nil interface on failure. A nil concrete pointer
|
|
// returned through the interface is not nil, so a caller's `next != nil` guard
|
|
// would pass and close() would run on a nil receiver.
|
|
func transportOrNil[T transport](t T, err error) (transport, error) {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
type mcpTool struct {
|
|
Name string `json:"name"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
OutputSchema json.RawMessage `json:"outputSchema,omitempty"`
|
|
// Annotations carries MCP's optional tool hints. readOnlyHint controls reader
|
|
// classification; destructiveHint remains destructive even when another hint
|
|
// claims the tool is read-only. Approval policy is applied separately.
|
|
Annotations json.RawMessage `json:"annotations,omitempty"`
|
|
}
|
|
|
|
// hints reads the two annotations that drive safety classification; a missing
|
|
// or malformed object declares neither.
|
|
func (t mcpTool) hints() (readOnly, destructive bool) {
|
|
var a struct {
|
|
ReadOnlyHint bool `json:"readOnlyHint"`
|
|
DestructiveHint bool `json:"destructiveHint"`
|
|
}
|
|
_ = json.Unmarshal(t.Annotations, &a)
|
|
return a.ReadOnlyHint, a.DestructiveHint
|
|
}
|
|
|
|
func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
|
|
c.toolsMu.Lock()
|
|
defer c.toolsMu.Unlock()
|
|
if c.toolsListed {
|
|
return append([]tool.Tool(nil), c.toolAdapters...), nil
|
|
}
|
|
return c.relistToolsLocked(ctx)
|
|
}
|
|
|
|
// relistToolsLocked answers a first listing: read one catalog and publish it.
|
|
// The caller holds toolsMu.
|
|
func (c *Client) relistToolsLocked(ctx context.Context) ([]tool.Tool, error) {
|
|
cat, err := c.readToolCatalog(ctx, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.publishToolCatalogLocked(cat)
|
|
return append([]tool.Tool(nil), cat.tools...), nil
|
|
}
|
|
|
|
func normalizeAndValidateToolSchema(raw json.RawMessage) (json.RawMessage, error) {
|
|
schema := canonicalizeSchema(raw)
|
|
if err := provider.ValidateToolSchema(schema); err != nil {
|
|
return nil, err
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
func schemaValidationError(err error) string {
|
|
const maxRunes = 512
|
|
msg := strings.TrimSpace(err.Error())
|
|
runes := []rune(msg)
|
|
if len(runes) > maxRunes {
|
|
msg = string(runes[:maxRunes]) + "..."
|
|
}
|
|
return "invalid input schema: " + msg
|
|
}
|
|
|
|
func (c *Client) listToolsRaw(ctx context.Context) ([]mcpTool, error) {
|
|
tools, meta, err := listAllPagesMeta[mcpTool](ctx, c, "tools/list", "tools")
|
|
if err == nil {
|
|
c.noteToolListTTL(meta.ttlMs)
|
|
}
|
|
return tools, err
|
|
}
|
|
|
|
// listToolsRawSettled gives dynamically registering servers a bounded startup
|
|
// window before their initial tool catalog is considered complete.
|
|
func (c *Client) listToolsRawSettled(ctx context.Context) ([]mcpTool, error) {
|
|
out, err := c.listToolsRaw(ctx)
|
|
if err != nil || !c.hasTools || len(out) > 0 {
|
|
return out, err
|
|
}
|
|
for _, delay := range advertisedToolsEmptyListRetryDelays {
|
|
if err := sleepContext(ctx, delay); err != nil {
|
|
return nil, err
|
|
}
|
|
out, err = c.listToolsRaw(ctx)
|
|
if err != nil || len(out) > 0 {
|
|
return out, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func validateMCPToolNames(server, stripRawPrefix string, tools []mcpTool) error {
|
|
seen := make(map[string]string, len(tools))
|
|
for _, candidate := range tools {
|
|
if strings.TrimSpace(candidate.Name) == "" {
|
|
return fmt.Errorf("%w: tools/list returned an empty tool name", ErrAmbiguousToolCatalog)
|
|
}
|
|
name := toolName(server, strings.TrimPrefix(candidate.Name, stripRawPrefix))
|
|
if previous, ok := seen[name]; ok {
|
|
return fmt.Errorf("%w: tools/list names %q and %q share model-visible name %q", ErrAmbiguousToolCatalog, previous, candidate.Name, name)
|
|
}
|
|
seen[name] = candidate.Name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sleepContext(ctx context.Context, delay time.Duration) error {
|
|
if delay <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *Client) cachedTools() ([]tool.Tool, bool) {
|
|
c.toolsMu.Lock()
|
|
defer c.toolsMu.Unlock()
|
|
if !c.toolsListed {
|
|
return nil, false
|
|
}
|
|
return append([]tool.Tool(nil), c.toolAdapters...), true
|
|
}
|
|
|
|
// 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 boundedToolName(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.DiagnosticError(err).Error()), " ")
|
|
const max = 500
|
|
if len(msg) > max {
|
|
cut := max
|
|
for cut > 0 && !utf8.RuneStart(msg[cut]) {
|
|
cut--
|
|
}
|
|
msg = msg[:cut] + "..."
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// JSON-RPC message types (shared by every transport)
|
|
|
|
type rpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id,omitempty"` // omitted for notifications (id 0 unused)
|
|
Method string `json:"method"`
|
|
Params any `json:"params,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *rpcError `json:"error"`
|
|
}
|
|
|
|
// 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
|
|
// paramHeaders are the arguments a modern HTTP server has mirrored into
|
|
// Mcp-Param headers; nil on every other connection.
|
|
paramHeaders []paramHeader
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.Lock()
|
|
defer t.client.toolsMu.Unlock()
|
|
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) {
|
|
if err := t.client.compositionContractError(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
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.securitySnapshot()
|
|
if tool.HasReaderExecutionIntent(ctx) {
|
|
// Final, linearizable check for a reader-authorized call: the snapshot
|
|
// above and every live security reconciliation serialize on the owning
|
|
// client's toolsMu. 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)
|
|
}
|
|
}
|
|
res, err := t.client.call(withParamHeaders(ctx, t.paramHeaders, argMap), "tools/call", map[string]any{
|
|
"name": t.rawName,
|
|
"arguments": argMap,
|
|
})
|
|
if err != nil {
|
|
return "", nil, t.client.scopeRefusal(err)
|
|
}
|
|
return parseToolResult(res)
|
|
}
|