v0.50.1 a637010

client.go RawHistoryFolder

1package mcp
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/http"
9	"net/url"
10	"path/filepath"
11	"runtime"
12	"strings"
13	"sync"
14	"sync/atomic"
15	"time"
16)
17
18// LatestProtocolVersion is the MCP revision zut requests during initialize.
19const LatestProtocolVersion = "2025-06-18"
20
21// supportedProtocolVersions lists revisions zut accepts from a server.
22var supportedProtocolVersions = map[string]bool{
23	"2025-06-18": true,
24	"2025-03-26": true,
25	"2024-11-05": true,
26}
27
28const (
29	// maxListPages bounds pagination of list requests.
30	maxListPages = 100
31	// maxListItems bounds the number of tools or resources kept per server.
32	maxListItems = 2048
33)
34
35// Tool is one MCP tool definition.
36type Tool struct {
37	Name        string           `json:"name"`
38	Title       string           `json:"title,omitempty"`
39	Description string           `json:"description,omitempty"`
40	InputSchema json.RawMessage  `json:"inputSchema,omitempty"`
41	Annotations *ToolAnnotations `json:"annotations,omitempty"`
42}
43
44// ToolAnnotations are untrusted hints supplied by the server.
45type ToolAnnotations struct {
46	Title           string `json:"title,omitempty"`
47	ReadOnlyHint    *bool  `json:"readOnlyHint,omitempty"`
48	DestructiveHint *bool  `json:"destructiveHint,omitempty"`
49	IdempotentHint  *bool  `json:"idempotentHint,omitempty"`
50	OpenWorldHint   *bool  `json:"openWorldHint,omitempty"`
51}
52
53// Resource is one entry from resources/list.
54type Resource struct {
55	URI         string `json:"uri"`
56	Name        string `json:"name,omitempty"`
57	Title       string `json:"title,omitempty"`
58	Description string `json:"description,omitempty"`
59	MimeType    string `json:"mimeType,omitempty"`
60}
61
62// ResourceTemplate is one entry from resources/templates/list.
63type ResourceTemplate struct {
64	URITemplate string `json:"uriTemplate"`
65	Name        string `json:"name,omitempty"`
66	Title       string `json:"title,omitempty"`
67	Description string `json:"description,omitempty"`
68	MimeType    string `json:"mimeType,omitempty"`
69}
70
71// Content is one MCP content block from a tool result or resource read.
72type Content struct {
73	Type     string           `json:"type"`
74	Text     string           `json:"text,omitempty"`
75	Data     string           `json:"data,omitempty"`
76	MimeType string           `json:"mimeType,omitempty"`
77	URI      string           `json:"uri,omitempty"`
78	Name     string           `json:"name,omitempty"`
79	Resource *ResourceContent `json:"resource,omitempty"`
80}
81
82// ResourceContent is the payload of an embedded resource or resources/read.
83type ResourceContent struct {
84	URI      string `json:"uri"`
85	MimeType string `json:"mimeType,omitempty"`
86	Text     string `json:"text,omitempty"`
87	Blob     string `json:"blob,omitempty"`
88}
89
90// CallResult is the tools/call result.
91type CallResult struct {
92	Content           []Content       `json:"content"`
93	StructuredContent json.RawMessage `json:"structuredContent,omitempty"`
94	IsError           bool            `json:"isError,omitempty"`
95}
96
97// ServerInfo is what the server reported during initialize.
98type ServerInfo struct {
99	Name            string
100	Version         string
101	ProtocolVersion string
102	Instructions    string
103	HasTools        bool
104	HasResources    bool
105	ToolsChanged    bool
106}
107
108// Client is one initialized MCP session.
109type Client struct {
110	name string
111	t    transport
112	cwd  string
113	next atomic.Int64
114	info ServerInfo
115
116	// onToolsChanged is invoked on notifications/tools/list_changed.
117	mu             sync.Mutex
118	onToolsChanged func()
119}
120
121// ClientInfo identifies zut to servers.
122type ClientInfo struct {
123	Name    string
124	Version string
125}
126
127// Connect starts the transport for cfg and performs the initialize handshake.
128func Connect(ctx context.Context, name string, cfg ServerConfig, cwd string, info ClientInfo, httpClient *http.Client) (*Client, error) {
129	return connectWith(ctx, name, cfg, cwd, info, httpClient, nil)
130}
131
132// connectWith is Connect with the tools/list_changed callback installed before
133// the handshake starts, so a notification sent right after initialize is not
134// lost.
135func connectWith(ctx context.Context, name string, cfg ServerConfig, cwd string, info ClientInfo, httpClient *http.Client, onToolsChanged func()) (*Client, error) {
136	var (
137		t   transport
138		err error
139	)
140	switch cfg.Kind() {
141	case "stdio":
142		var st *stdioTransport
143		st, err = startStdio(name, cfg, cwd)
144		if err == nil {
145			t = st
146		}
147	case "http":
148		var ht *httpTransport
149		ht, err = newHTTPTransport(name, cfg, httpClient)
150		if err == nil {
151			t = ht
152		}
153	default:
154		err = fmt.Errorf("unsupported transport %q", cfg.Kind())
155	}
156	if err != nil {
157		return nil, err
158	}
159	c := &Client{name: name, t: t, cwd: cwd, onToolsChanged: onToolsChanged}
160	t.setHandler(c.handleIncoming)
161	if err := c.initialize(ctx, info); err != nil {
162		// Close first: it reaps the process and waits for stderr to drain, so
163		// the tail is complete (a server that fails fast writes its reason
164		// to stderr just before exiting).
165		_ = t.close()
166		tail := ""
167		if st, ok := t.(*stdioTransport); ok {
168			tail = st.stderrTail()
169		}
170		if tail != "" {
171			return nil, fmt.Errorf("%w\nserver stderr:\n%s", err, tail)
172		}
173		return nil, err
174	}
175	if ht, ok := t.(*httpTransport); ok && c.info.ToolsChanged {
176		ht.listen()
177	}
178	return c, nil
179}
180
181func (c *Client) initialize(ctx context.Context, info ClientInfo) error {
182	if info.Name == "" {
183		info.Name = "zut"
184	}
185	params := map[string]any{
186		"protocolVersion": LatestProtocolVersion,
187		"capabilities": map[string]any{
188			"roots": map[string]any{"listChanged": false},
189		},
190		"clientInfo": map[string]any{"name": info.Name, "title": "zut", "version": info.Version},
191	}
192	raw, err := c.call(ctx, "initialize", params)
193	if err != nil {
194		return fmt.Errorf("initialize: %w", err)
195	}
196	var result struct {
197		ProtocolVersion string `json:"protocolVersion"`
198		Capabilities    struct {
199			Tools *struct {
200				ListChanged bool `json:"listChanged"`
201			} `json:"tools"`
202			Resources *json.RawMessage `json:"resources"`
203		} `json:"capabilities"`
204		ServerInfo struct {
205			Name    string `json:"name"`
206			Version string `json:"version"`
207		} `json:"serverInfo"`
208		Instructions string `json:"instructions"`
209	}
210	if err := json.Unmarshal(raw, &result); err != nil {
211		return fmt.Errorf("initialize: decode result: %w", err)
212	}
213	if !supportedProtocolVersions[result.ProtocolVersion] {
214		return fmt.Errorf("initialize: unsupported protocol version %q", result.ProtocolVersion)
215	}
216	c.info = ServerInfo{
217		Name:            result.ServerInfo.Name,
218		Version:         result.ServerInfo.Version,
219		ProtocolVersion: result.ProtocolVersion,
220		Instructions:    result.Instructions,
221		HasTools:        result.Capabilities.Tools != nil,
222		HasResources:    result.Capabilities.Resources != nil,
223		ToolsChanged:    result.Capabilities.Tools != nil && result.Capabilities.Tools.ListChanged,
224	}
225	c.t.setProtocolVersion(result.ProtocolVersion)
226	if err := c.t.notify(ctx, "notifications/initialized", nil); err != nil {
227		return fmt.Errorf("initialized notification: %w", err)
228	}
229	return nil
230}
231
232// Info returns the negotiated server information.
233func (c *Client) Info() ServerInfo { return c.info }
234
235// Done is closed when the connection becomes unusable.
236func (c *Client) Done() <-chan struct{} { return c.t.done() }
237
238// Close ends the session and stops a stdio server process.
239func (c *Client) Close() error { return c.t.close() }
240
241// SetToolsChanged installs the tools/list_changed callback.
242func (c *Client) SetToolsChanged(fn func()) {
243	c.mu.Lock()
244	c.onToolsChanged = fn
245	c.mu.Unlock()
246}
247
248func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
249	return c.t.request(ctx, c.next.Add(1), method, params)
250}
251
252func (c *Client) handleIncoming(msg message) (any, *RPCError) {
253	switch msg.Method {
254	case "ping":
255		return struct{}{}, nil
256	case "roots/list":
257		roots := []map[string]string{}
258		if c.cwd != "" {
259			if abs, err := filepath.Abs(c.cwd); err == nil {
260				roots = append(roots, map[string]string{"uri": pathToFileURI(abs), "name": filepath.Base(abs)})
261			}
262		}
263		return map[string]any{"roots": roots}, nil
264	case "notifications/tools/list_changed":
265		c.mu.Lock()
266		fn := c.onToolsChanged
267		c.mu.Unlock()
268		if fn != nil {
269			fn()
270		}
271		return nil, nil
272	}
273	if len(msg.ID) == 0 {
274		return nil, nil // other notifications (logging, progress) are ignored
275	}
276	return nil, &RPCError{Code: codeMethodNotFound, Message: "method not supported by zut: " + msg.Method}
277}
278
279// ListTools returns every tool, following pagination cursors.
280func (c *Client) ListTools(ctx context.Context) ([]Tool, error) {
281	if !c.info.HasTools {
282		return nil, nil
283	}
284	var out []Tool
285	err := paginate(ctx, c, "tools/list", func(raw json.RawMessage) (string, error) {
286		var page struct {
287			Tools      []Tool `json:"tools"`
288			NextCursor string `json:"nextCursor"`
289		}
290		if err := json.Unmarshal(raw, &page); err != nil {
291			return "", err
292		}
293		out = append(out, page.Tools...)
294		if len(out) > maxListItems {
295			out = out[:maxListItems]
296			return "", nil
297		}
298		return page.NextCursor, nil
299	})
300	return out, err
301}
302
303// ListResources returns every resource, following pagination cursors.
304func (c *Client) ListResources(ctx context.Context) ([]Resource, error) {
305	if !c.info.HasResources {
306		return nil, nil
307	}
308	var out []Resource
309	err := paginate(ctx, c, "resources/list", func(raw json.RawMessage) (string, error) {
310		var page struct {
311			Resources  []Resource `json:"resources"`
312			NextCursor string     `json:"nextCursor"`
313		}
314		if err := json.Unmarshal(raw, &page); err != nil {
315			return "", err
316		}
317		out = append(out, page.Resources...)
318		if len(out) > maxListItems {
319			out = out[:maxListItems]
320			return "", nil
321		}
322		return page.NextCursor, nil
323	})
324	return out, err
325}
326
327// ListResourceTemplates returns every resource template.
328func (c *Client) ListResourceTemplates(ctx context.Context) ([]ResourceTemplate, error) {
329	if !c.info.HasResources {
330		return nil, nil
331	}
332	var out []ResourceTemplate
333	err := paginate(ctx, c, "resources/templates/list", func(raw json.RawMessage) (string, error) {
334		var page struct {
335			ResourceTemplates []ResourceTemplate `json:"resourceTemplates"`
336			NextCursor        string             `json:"nextCursor"`
337		}
338		if err := json.Unmarshal(raw, &page); err != nil {
339			return "", err
340		}
341		out = append(out, page.ResourceTemplates...)
342		if len(out) > maxListItems {
343			out = out[:maxListItems]
344			return "", nil
345		}
346		return page.NextCursor, nil
347	})
348	return out, err
349}
350
351// ReadResource reads one resource URI.
352func (c *Client) ReadResource(ctx context.Context, uri string) ([]ResourceContent, error) {
353	raw, err := c.call(ctx, "resources/read", map[string]any{"uri": uri})
354	if err != nil {
355		return nil, err
356	}
357	var result struct {
358		Contents []ResourceContent `json:"contents"`
359	}
360	if err := json.Unmarshal(raw, &result); err != nil {
361		return nil, fmt.Errorf("decode resources/read: %w", err)
362	}
363	return result.Contents, nil
364}
365
366// CallTool invokes one tool by its raw MCP name.
367func (c *Client) CallTool(ctx context.Context, name string, args json.RawMessage) (CallResult, error) {
368	if len(args) == 0 {
369		args = json.RawMessage(`{}`)
370	}
371	var probe map[string]json.RawMessage
372	if err := json.Unmarshal(args, &probe); err != nil || probe == nil {
373		return CallResult{}, errors.New("tool arguments must be a JSON object")
374	}
375	raw, err := c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args})
376	if err != nil {
377		return CallResult{}, err
378	}
379	var result CallResult
380	if err := json.Unmarshal(raw, &result); err != nil {
381		return CallResult{}, fmt.Errorf("decode tools/call: %w", err)
382	}
383	return result, nil
384}
385
386func paginate(ctx context.Context, c *Client, method string, page func(json.RawMessage) (string, error)) error {
387	cursor := ""
388	seen := map[string]bool{}
389	for i := 0; i < maxListPages; i++ {
390		var params any
391		if cursor != "" {
392			params = map[string]any{"cursor": cursor}
393		}
394		raw, err := c.call(ctx, method, params)
395		if err != nil {
396			return fmt.Errorf("%s: %w", method, err)
397		}
398		next, err := page(raw)
399		if err != nil {
400			return fmt.Errorf("%s: decode: %w", method, err)
401		}
402		if next == "" {
403			return nil
404		}
405		if seen[next] {
406			return fmt.Errorf("%s: server repeated pagination cursor", method)
407		}
408		seen[next] = true
409		cursor = next
410	}
411	return fmt.Errorf("%s: more than %d pages", method, maxListPages)
412}
413
414// withTimeout returns ctx bounded by d when d > 0.
415func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
416	if d <= 0 {
417		return context.WithCancel(ctx)
418	}
419	return context.WithTimeout(ctx, d)
420}
421
422// pathToFileURI converts an absolute path of the running platform to a file URI.
423func pathToFileURI(path string) string {
424	return fileURI(path, runtime.GOOS == "windows")
425}
426
427// fileURI builds a file URI from an absolute path. windows selects Windows
428// path syntax (drive letters, UNC shares, backslashes) and is a parameter so
429// both shapes can be tested on any platform:
430//
431//	C:\Users\me\proj   -> file:///C:/Users/me/proj
432//	\\host\share\dir   -> file://host/share/dir
433//	/home/me/my proj    -> file:///home/me/my%20proj
434func fileURI(path string, windows bool) string {
435	u := url.URL{Scheme: "file"}
436	if !windows {
437		u.Path = path
438		return u.String()
439	}
440	p := path
441	// Strip the extended-length prefixes: \\?\C:\x and \\?\UNC\host\share.
442	switch {
443	case strings.HasPrefix(p, `\\?\UNC\`):
444		p = `\\` + p[len(`\\?\UNC\`):]
445	case strings.HasPrefix(p, `\\?\`):
446		p = p[len(`\\?\`):]
447	}
448	p = strings.ReplaceAll(p, `\`, "/")
449	if strings.HasPrefix(p, "//") {
450		host, rest, _ := strings.Cut(strings.TrimLeft(p, "/"), "/")
451		u.Host = host
452		u.Path = "/" + rest
453		return u.String()
454	}
455	if !strings.HasPrefix(p, "/") {
456		p = "/" + p // C:/x -> /C:/x
457	}
458	u.Path = p
459	return u.String()
460}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close