main
1package fetch
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/xlgmokha/mcp/pkg/mcp"
14)
15
16type FetchResult struct {
17 URL string `json:"url"`
18 Content string `json:"content"`
19 ContentType string `json:"content_type"`
20 IsBinary bool `json:"is_binary"`
21}
22
23type FetchOperations struct {
24 httpClient *http.Client
25 userAgent string
26}
27
28func NewFetchOperations() *FetchOperations {
29 return &FetchOperations{
30 httpClient: &http.Client{
31 Timeout: 30 * time.Second,
32 },
33 userAgent: "ModelContextProtocol/1.0 (Fetch; +https://github.com/xlgmokha/mcp)",
34 }
35}
36
37// New creates a new Fetch MCP server
38func New() *mcp.Server {
39 fetch := NewFetchOperations()
40 builder := mcp.NewServerBuilder("mcp-fetch", "1.0.0")
41
42 builder.AddTool(mcp.NewTool("fetch", "Fetches a URL and returns the content. Text content is returned as-is, binary content is base64 encoded.", map[string]interface{}{
43 "type": "object",
44 "properties": map[string]interface{}{
45 "url": map[string]interface{}{
46 "type": "string",
47 "description": "URL to fetch",
48 "format": "uri",
49 },
50 },
51 "required": []string{"url"},
52 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
53 urlStr, ok := req.Arguments["url"].(string)
54 if !ok {
55 return mcp.NewToolError("url is required"), nil
56 }
57
58 parsedURL, err := url.Parse(urlStr)
59 if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
60 return mcp.NewToolError("Invalid URL format"), nil
61 }
62
63 result, err := fetch.fetchContent(parsedURL.String())
64 if err != nil {
65 return mcp.NewToolError(err.Error()), nil
66 }
67
68 jsonResult, err := json.MarshalIndent(result, "", " ")
69 if err != nil {
70 return mcp.NewToolError(fmt.Sprintf("Failed to marshal result: %v", err)), nil
71 }
72
73 return mcp.NewToolResult(mcp.NewTextContent(string(jsonResult))), nil
74 }))
75
76 return builder.Build()
77}
78
79func (fetch *FetchOperations) fetchContent(urlStr string) (*FetchResult, error) {
80 req, err := http.NewRequest("GET", urlStr, nil)
81 if err != nil {
82 return nil, fmt.Errorf("Failed to create request: %v", err)
83 }
84
85 req.Header.Set("User-Agent", fetch.userAgent)
86
87 resp, err := fetch.httpClient.Do(req)
88 if err != nil {
89 return nil, fmt.Errorf("Failed to fetch URL: %v", err)
90 }
91 defer resp.Body.Close()
92
93 if resp.StatusCode >= 400 {
94 return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
95 }
96
97 body, err := io.ReadAll(resp.Body)
98 if err != nil {
99 return nil, fmt.Errorf("Failed to read response body: %v", err)
100 }
101
102 contentType := resp.Header.Get("Content-Type")
103 isBinary := isBinaryContent(contentType)
104
105 var content string
106 if isBinary {
107 content = base64.StdEncoding.EncodeToString(body)
108 } else {
109 content = string(body)
110 }
111
112 return &FetchResult{
113 URL: urlStr,
114 Content: content,
115 ContentType: contentType,
116 IsBinary: isBinary,
117 }, nil
118}
119
120func isBinaryContent(contentType string) bool {
121 if contentType == "" {
122 return false
123 }
124
125 contentType = strings.ToLower(strings.Split(contentType, ";")[0])
126
127 textTypes := []string{
128 "text/",
129 "application/json",
130 "application/xml",
131 "application/javascript",
132 "application/x-javascript",
133 }
134
135 for _, textType := range textTypes {
136 if strings.HasPrefix(contentType, textType) {
137 return false
138 }
139 }
140
141 return true
142}