Files
Maven/internal/mcp/client.go
claude 8c1e457150 an mcp answer with no result is not a success (V-613)
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.
2026-08-06 04:18:53 +04:00

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
}