Documentation
¶
Overview ¶
Package mcpclient is a minimal Model Context Protocol (MCP) client for server-side rysh components.
It exists because the chatbot's LLM pane runs inside rysh-server, which had no way to reach an MCP server: the complete client under rysh-cli/internal/mcp lives in a module deliberately outside go.work and is package-private besides, so rysh-server cannot import it. This package is the server-side half — the wire shapes are kept identical to the CLI's so the two cannot drift into speaking different dialects of the same protocol.
Scope, deliberately small: the Streamable-HTTP transport and the three methods a tool consumer needs — initialize, tools/list, tools/call. The CLI's process lifecycle, heartbeats, auto-restart and stdio transport are not reproduced here; a hosted customer MCP server speaks HTTP, and a server-side process supervisor is a different problem than the one this solves.
Discovered tools are adapted to tools.ToolExecutor (see executor.go) so they register into the same registry as native rysh tools and flow through the existing tool-use bridge, approval gate, and audit path unchanged.
Index ¶
- Constants
- type ApprovalPolicy
- type CallToolResult
- type Client
- func (c *Client) CallTool(ctx context.Context, name string, args json.RawMessage) (*CallToolResult, error)
- func (c *Client) Close() error
- func (c *Client) DroppedTools() int
- func (c *Client) Executors(list []Tool) []sharedtools.ToolExecutor
- func (c *Client) Initialize(ctx context.Context) error
- func (c *Client) ListTools(ctx context.Context) ([]Tool, error)
- func (c *Client) ServerInfo() (name, version string)
- type Config
- type Tool
- type URLGuard
Constants ¶
const ( // DefaultTimeout bounds a single JSON-RPC round trip. A tool call that // queries a customer's live business data is slower than a page fetch, so // this is looser than browse_web's 10 s — but it is still a hard bound, and // the pane's own context cancels earlier when the visitor moves on. DefaultTimeout = 15 * time.Second // DefaultMaxResponseBytes caps one HTTP response body. Larger than the HTTP // tool's 256 KB because a tools/list catalog is structural rather than // model-facing prose, small enough that a hostile server cannot exhaust // server memory through a tool the model called on its own initiative. DefaultMaxResponseBytes = 1 << 20 // DefaultMaxTools bounds how many tools one server may contribute. Every // adopted tool costs prompt tokens on every turn, so an unbounded catalog // is a cost and a context-window problem before it is a security one. DefaultMaxTools = 64 )
Hardening defaults. They sit in the same band as the sibling server-side tools that also make outbound calls on a model's behalf: browse_web uses 2 MB / 10 s, the owner-configured HTTP tool caps bodies at 256 KB.
const ProtocolVersion = "2024-11-05"
ProtocolVersion is the MCP revision this client advertises during the initialize handshake. It matches rysh-cli/internal/mcp and the reference servers under rysh-mcp-samples.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ApprovalPolicy ¶
type ApprovalPolicy int
ApprovalPolicy decides whether tools discovered from a server are gated behind the orchestrator's approval prompt.
const ( // ApprovalAlways gates every tool from the server. It is the zero value, so // a caller that forgets to think about approval fails closed. // // Why this is the default: MCP gives a client no way to tell a read from a // write. A name and a description are all a tool ships, both written by the // third party that also implements the tool. Later protocol revisions add // annotations.readOnlyHint, but that is the server's self-declaration about // its own safety — precisely the claim an attacker would forge — so this // package does not use it to lower a gate. The party who can classify the // risk is the operator who chose to configure the server URL, and this // policy is where they record that judgement. ApprovalAlways ApprovalPolicy = iota // ApprovalNever ungates the server's tools. Set it only for a server the // operator has vetted — typically their own business-data endpoint, whose // tools exist to be called autonomously by an unattended chatbot pane. ApprovalNever )
type CallToolResult ¶
type CallToolResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
CallToolResult is the server's reply to "tools/call". IsError distinguishes "the tool ran and reported a failure" (recoverable by the model) from a transport or protocol failure (returned as a Go error).
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a protocol-level MCP client over a single Streamable-HTTP connection. It performs the handshake, lists tools, and calls them. It is safe for concurrent use.
func Connect ¶
func Connect(ctx context.Context, cfg Config) (*Client, []sharedtools.ToolExecutor, error)
Connect is the one-call path a caller wants: validate, handshake, list tools, and return them adapted to the shared ToolExecutor interface. On error the client is closed before returning.
func New ¶
New validates the configuration and builds a client. It performs no I/O; call Initialize (or use Connect) to reach the server.
func (*Client) CallTool ¶
func (c *Client) CallTool(ctx context.Context, name string, args json.RawMessage) (*CallToolResult, error)
CallTool invokes a tool by its server-side name with raw JSON arguments.
func (*Client) DroppedTools ¶
DroppedTools reports how many tools the MaxTools cap discarded on the last ListTools. It counts only tools already fetched when the cap tripped — remaining pages are never requested, so a non-zero value means "at least this many". Callers should log it: a silently truncated catalog reads to an operator as "the server only has these tools".
func (*Client) Executors ¶
func (c *Client) Executors(list []Tool) []sharedtools.ToolExecutor
Executors adapts listed MCP tools to the shared ToolExecutor interface, so they register into the same ToolRegistry as native rysh tools and flow through the existing tool-use bridge, approval gate, and audit path.
func (*Client) Initialize ¶
Initialize performs the MCP handshake: "initialize", then the "notifications/initialized" acknowledgement.
func (*Client) ListTools ¶
ListTools returns the tools the server advertises, following nextCursor pagination. The result is capped at Config.MaxTools; DroppedTools reports how many were discarded so a caller can log the cap rather than silently present a partial catalog as complete.
func (*Client) ServerInfo ¶
ServerInfo returns the name/version reported by the server (valid after Initialize).
type Config ¶
type Config struct {
// Name is a short alias for the server. It prefixes every discovered tool
// name so two servers exposing "search" do not collide in one registry, and
// it identifies the server in errors.
Name string
// URL is the Streamable-HTTP MCP endpoint (typically ".../mcp").
URL string
// Headers are sent on every request — this is where an Authorization
// header for the customer's server goes.
Headers map[string]string
// Guard authorizes the endpoint. Required; see URLGuard.
Guard URLGuard
// Timeout bounds one round trip. Zero uses DefaultTimeout.
Timeout time.Duration
// MaxResponseBytes caps one response body. Zero uses
// DefaultMaxResponseBytes; a body over the cap is an error, never a
// truncated parse.
MaxResponseBytes int64
// MaxTools bounds adopted tools. Zero uses DefaultMaxTools.
MaxTools int
// Approval selects the approval policy. The zero value is ApprovalAlways.
Approval ApprovalPolicy
// HTTPClient overrides the transport client. When nil a client is built
// with the guard installed as its redirect checker. A supplied client has
// its CheckRedirect replaced for the same reason.
HTTPClient *http.Client
}
Config describes one MCP server connection.
type Tool ¶
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
Tool is a tool advertised by an MCP server. InputSchema stays raw JSON so it can be handed to tools.ToolSpec.Parameters (also a JSON Schema) without a lossy round-trip through a typed struct.
type URLGuard ¶
URLGuard authorizes one outbound request target. It is applied to the configured endpoint before every request and again to every redirect hop.
--------------------------------------------------------------------------- Which layer owns SSRF protection: the caller, not this package. ---------------------------------------------------------------------------
This package owns *transport* hardening — timeout, response-size cap, tool count, redirect bound. It deliberately does not own *network-position* policy (http(s)-only is checked here as basic input validation, but "is this host publicly routable" is not), for one reason: rysh-server already has that policy, implemented and tested, in internal/agentic/chatbot_ssrf_guard.go, and its own header says a second copy is "a future vulnerability, not a convenience". Duplicating it in rysh-shared would create exactly the drift it warns about — and rysh-shared has no visibility into a deployment's egress rules anyway.
So the policy is injected, and Guard is REQUIRED: New refuses a nil Guard rather than defaulting to allow-all. That refusal is the whole point. The custom HTTP tool shipped with a `strings.HasPrefix(url, "http")` check and nothing else (design 016 gap 3), which let an owner aim a tool at the cloud metadata endpoint. A library default of "no guard means no guarding" is how that happens again; a compile-time-visible required field is how it does not.
Wave 2 (wiring MCP into chatbot_configs) must pass rysh-server's strictURLGuard here. Tests against a loopback httptest server pass AllowAnyURL.
func AllowAnyURL ¶
func AllowAnyURL() URLGuard
AllowAnyURL is an explicit, deliberately loud escape hatch that authorizes every target. It is for tests binding to loopback and for callers that have already applied their own egress policy at a lower layer (an egress proxy, a network policy). Production code paths that reach arbitrary customer-supplied URLs must pass a real guard instead.