8c1e457150
Two defects in internal/mcp, both about a call that reports done when nothing happened. Client.call accepted a frame carrying our id and neither result nor error. The HTTP transport already refuses one; the stdio transport does not, so the refusal depended on which door the server was behind. Down that path tools/call returns an empty string and a nil error, and the act is recorded as run. Manager.ReadResource reported ErrNoServer for a server that is configured but down. Call keeps those two apart on purpose — one says the tool can never exist, the other says not right now.
277 lines
8.1 KiB
Go
277 lines
8.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// Errors callers distinguish.
|
|
var (
|
|
// ErrClosed — the transport is gone (subprocess died, client closed).
|
|
ErrClosed = errors.New("mcp: connection is closed")
|
|
// ErrNotInitialized — a call was made before the initialize handshake.
|
|
ErrNotInitialized = errors.New("mcp: not initialized")
|
|
// ErrToolFailed — the server ran the tool and reported an error result.
|
|
ErrToolFailed = errors.New("mcp: tool reported an error")
|
|
)
|
|
|
|
// Tool is one tool a server offers, in the form Maven cares about.
|
|
//
|
|
// ReadOnly comes from the server's own readOnlyHint annotation and decides
|
|
// whether the allowlist row is marked destructive: no hint, or a false one,
|
|
// means "assume it mutates", which routes the call through the confirm turn.
|
|
// Guessing wrong in that direction only costs a question.
|
|
type Tool struct {
|
|
Server string
|
|
Name string
|
|
Description string
|
|
InputSchema json.RawMessage
|
|
ReadOnly bool
|
|
}
|
|
|
|
// Resource is one resource a server offers. Contents are fetched separately —
|
|
// listing is cheap, reading is not.
|
|
type Resource struct {
|
|
Server string
|
|
URI string
|
|
Name string
|
|
MIMEType string
|
|
}
|
|
|
|
// ServerInfo is what came back from the handshake.
|
|
type ServerInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
ProtocolVersion string `json:"-"`
|
|
}
|
|
|
|
// Client is one connected MCP server. Safe for concurrent use.
|
|
type Client struct {
|
|
name string
|
|
tr transport
|
|
next atomic.Int64
|
|
|
|
mu sync.Mutex
|
|
info ServerInfo
|
|
ready bool
|
|
}
|
|
|
|
// newClient wraps a transport. Callers use Dial* in manager.go.
|
|
func newClient(name string, tr transport) *Client {
|
|
return &Client{name: name, tr: tr}
|
|
}
|
|
|
|
// Name — the local name of this server (the config key, not the server's own).
|
|
func (c *Client) Name() string { return c.name }
|
|
|
|
// Info — what the server said about itself during the handshake.
|
|
func (c *Client) Info() ServerInfo {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.info
|
|
}
|
|
|
|
// Initialize performs the MCP handshake and sends notifications/initialized.
|
|
// Capabilities we declare are empty on purpose: Maven consumes, she does not
|
|
// offer sampling or roots back to the server.
|
|
func (c *Client) Initialize(ctx context.Context) error {
|
|
var out struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
ServerInfo ServerInfo `json:"serverInfo"`
|
|
}
|
|
err := c.call(ctx, "initialize", map[string]any{
|
|
"protocolVersion": ProtocolVersion,
|
|
"capabilities": map[string]any{},
|
|
"clientInfo": map[string]any{"name": "maven", "version": "1.0"},
|
|
}, &out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(out.ProtocolVersion) == "" {
|
|
return fmt.Errorf("mcp: %s: handshake returned no protocol version", c.name)
|
|
}
|
|
out.ServerInfo.ProtocolVersion = out.ProtocolVersion
|
|
c.mu.Lock()
|
|
c.info, c.ready = out.ServerInfo, true
|
|
c.mu.Unlock()
|
|
// Best effort: a stateless HTTP server may not care, and a failure here is
|
|
// not worth dropping a working connection over.
|
|
_ = c.tr.Notify(ctx, "notifications/initialized", map[string]any{})
|
|
return nil
|
|
}
|
|
|
|
// ListTools discovers the server's tools.
|
|
func (c *Client) ListTools(ctx context.Context) ([]Tool, error) {
|
|
if !c.initialized() {
|
|
return nil, ErrNotInitialized
|
|
}
|
|
var out struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
Annotations *struct {
|
|
ReadOnlyHint bool `json:"readOnlyHint"`
|
|
} `json:"annotations"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := c.call(ctx, "tools/list", map[string]any{}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
tools := make([]Tool, 0, len(out.Tools))
|
|
for _, t := range out.Tools {
|
|
if strings.TrimSpace(t.Name) == "" {
|
|
continue
|
|
}
|
|
tools = append(tools, Tool{
|
|
Server: c.name,
|
|
Name: t.Name,
|
|
Description: strings.TrimSpace(t.Description),
|
|
InputSchema: t.InputSchema,
|
|
ReadOnly: t.Annotations != nil && t.Annotations.ReadOnlyHint,
|
|
})
|
|
}
|
|
return tools, nil
|
|
}
|
|
|
|
// CallTool runs one tool and returns its text content, joined by newlines.
|
|
// Non-text content (images, blobs) is dropped: everything downstream of here
|
|
// is a spoken or written sentence.
|
|
//
|
|
// args is exactly what the router produced. Nothing else — no history, no
|
|
// notes, no persona — is in scope here, by construction.
|
|
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
|
if !c.initialized() {
|
|
return "", ErrNotInitialized
|
|
}
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
var out struct {
|
|
IsError bool `json:"isError"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args}, &out); err != nil {
|
|
return "", err
|
|
}
|
|
var parts []string
|
|
for _, ct := range out.Content {
|
|
if ct.Type == "text" && strings.TrimSpace(ct.Text) != "" {
|
|
parts = append(parts, strings.TrimSpace(ct.Text))
|
|
}
|
|
}
|
|
text := strings.Join(parts, "\n")
|
|
if out.IsError {
|
|
return text, fmt.Errorf("%w: %s/%s: %s", ErrToolFailed, c.name, name, text)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// ListResources discovers the server's resources. A server without the
|
|
// resources capability answers with an error; that is not fatal, the caller
|
|
// gets an empty list.
|
|
func (c *Client) ListResources(ctx context.Context) ([]Resource, error) {
|
|
if !c.initialized() {
|
|
return nil, ErrNotInitialized
|
|
}
|
|
var out struct {
|
|
Resources []struct {
|
|
URI string `json:"uri"`
|
|
Name string `json:"name"`
|
|
MIMEType string `json:"mimeType"`
|
|
} `json:"resources"`
|
|
}
|
|
if err := c.call(ctx, "resources/list", map[string]any{}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
res := make([]Resource, 0, len(out.Resources))
|
|
for _, r := range out.Resources {
|
|
if strings.TrimSpace(r.URI) == "" {
|
|
continue
|
|
}
|
|
res = append(res, Resource{Server: c.name, URI: r.URI, Name: r.Name, MIMEType: r.MIMEType})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// ReadResource returns a resource's text contents, joined by newlines. This is
|
|
// the RAG-hint path: the text can be pasted into a router or phraser prompt.
|
|
func (c *Client) ReadResource(ctx context.Context, uri string) (string, error) {
|
|
if !c.initialized() {
|
|
return "", ErrNotInitialized
|
|
}
|
|
var out struct {
|
|
Contents []struct {
|
|
Text string `json:"text"`
|
|
} `json:"contents"`
|
|
}
|
|
if err := c.call(ctx, "resources/read", map[string]any{"uri": uri}, &out); err != nil {
|
|
return "", err
|
|
}
|
|
var parts []string
|
|
for _, ct := range out.Contents {
|
|
if strings.TrimSpace(ct.Text) != "" {
|
|
parts = append(parts, strings.TrimSpace(ct.Text))
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n"), nil
|
|
}
|
|
|
|
// Close drops the connection.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
c.ready = false
|
|
c.mu.Unlock()
|
|
return c.tr.Close()
|
|
}
|
|
|
|
func (c *Client) initialized() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.ready
|
|
}
|
|
|
|
// alive reports whether the underlying transport can still carry a call. HTTP
|
|
// is stateless, so it is always alive; a dead subprocess is not.
|
|
func (c *Client) alive() bool {
|
|
if s, ok := c.tr.(*stdioTransport); ok {
|
|
return s.alive()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Client) call(ctx context.Context, method string, params any, out any) error {
|
|
req := &rpcRequest{JSONRPC: "2.0", ID: c.next.Add(1), Method: method, Params: params}
|
|
resp, err := c.tr.Call(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, err)
|
|
}
|
|
if resp.Error != nil {
|
|
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
|
|
}
|
|
// A frame carrying our id and neither result nor error is not an answer.
|
|
// The HTTP transport already refuses one; the stdio transport does not, and
|
|
// without this check the refusal depended on which door the server was
|
|
// behind. Letting it through is the one failure that lies: tools/call
|
|
// returns an empty string and a nil error, so the act is recorded as done
|
|
// and the tool never ran.
|
|
if len(resp.Result) == 0 {
|
|
return fmt.Errorf("mcp: %s: %s: response carries neither result nor error", c.name, method)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(resp.Result, out); err != nil {
|
|
return fmt.Errorf("mcp: %s: %s: decode result: %w", c.name, method, err)
|
|
}
|
|
return nil
|
|
}
|