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 }