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}