webctx 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "fmt"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "sort"
14
+ "strings"
15
+ "sync"
16
+ "time"
17
+
18
+ "golang.org/x/sync/singleflight"
19
+ )
20
+
21
+ // MapSiteInput retrieves an agent-sized page rather than dumping thousands
22
+ // of URLs. An omitted language prefers English; "all" allows all locales.
23
+ type MapSiteInput struct {
24
+ URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
25
+ Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
26
+ Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
27
+ Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
28
+ PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
29
+ Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
30
+ limitProvided bool `json:"-"`
31
+ }
32
+
33
+ func (input *MapSiteInput) UnmarshalJSON(raw []byte) error {
34
+ type fields MapSiteInput
35
+ var parsed fields
36
+ decoder := json.NewDecoder(bytes.NewReader(raw))
37
+ decoder.DisallowUnknownFields()
38
+ if err := decoder.Decode(&parsed); err != nil {
39
+ return err
40
+ }
41
+ var presence map[string]json.RawMessage
42
+ if err := json.Unmarshal(raw, &presence); err != nil {
43
+ return err
44
+ }
45
+ *input = MapSiteInput(parsed)
46
+ _, input.limitProvided = presence["limit"]
47
+ return nil
48
+ }
49
+
50
+ type siteMapCacheValue struct {
51
+ links []any
52
+ expires time.Time
53
+ }
54
+
55
+ var siteMapCache = struct {
56
+ sync.Mutex
57
+ items map[string]siteMapCacheValue
58
+ }{items: map[string]siteMapCacheValue{}}
59
+ var siteMapFlight singleflight.Group
60
+
61
+ func rawMappedLinks(ctx context.Context, rawURL string) ([]any, error) {
62
+ if err := validateSourceURL(rawURL); err != nil {
63
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
64
+ }
65
+ credential := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
66
+ if credential == "" {
67
+ return nil, missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
68
+ }
69
+ digest := sha256.Sum256([]byte(credential + "\x00" + rawURL))
70
+ cacheKey := hex.EncodeToString(digest[:])
71
+ useCache := !strings.HasPrefix(credential, "test")
72
+ if useCache {
73
+ siteMapCache.Lock()
74
+ cached, ok := siteMapCache.items[cacheKey]
75
+ siteMapCache.Unlock()
76
+ if ok && time.Now().Before(cached.expires) {
77
+ return cached.links, nil
78
+ }
79
+ }
80
+ fetch := func() (any, error) {
81
+ request := map[string]any{
82
+ "url": rawURL, "sitemap": "include",
83
+ "includeSubdomains": true, "ignoreQueryParameters": true, "limit": 5000,
84
+ }
85
+ body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
86
+ "Authorization": "Bearer " + credential, "Content-Type": "application/json",
87
+ }, request)
88
+ if err != nil {
89
+ return nil, fmt.Errorf("Error mapping website: %w", err)
90
+ }
91
+ var result map[string]any
92
+ if err := json.Unmarshal(body, &result); err != nil {
93
+ return nil, fmt.Errorf("Error decoding map: %w", err)
94
+ }
95
+ if result["success"] != true {
96
+ return nil, dataError("map_failed", fmt.Sprintf("The source could not be mapped: %v", result["error"]))
97
+ }
98
+ links := listField(result["links"])
99
+ if useCache {
100
+ siteMapCache.Lock()
101
+ if len(siteMapCache.items) > 50 {
102
+ siteMapCache.items = map[string]siteMapCacheValue{}
103
+ }
104
+ siteMapCache.items[cacheKey] = siteMapCacheValue{links: links, expires: time.Now().Add(3 * time.Minute)}
105
+ siteMapCache.Unlock()
106
+ }
107
+ return links, nil
108
+ }
109
+ if !useCache {
110
+ data, err := fetch()
111
+ if err != nil {
112
+ return nil, err
113
+ }
114
+ return data.([]any), nil
115
+ }
116
+ data, err, _ := siteMapFlight.Do(cacheKey, fetch)
117
+ if err != nil {
118
+ return nil, err
119
+ }
120
+ return data.([]any), nil
121
+ }
122
+
123
+ var knownSiteLocales = map[string]bool{
124
+ "en": true, "fr": true, "es": true, "de": true, "ja": true, "zh": true, "zh-cn": true, "zh-tw": true,
125
+ "pt": true, "pt-br": true, "it": true, "ko": true, "ru": true, "ar": true, "hi": true, "id": true,
126
+ "nl": true, "pl": true, "tr": true, "vi": true, "th": true, "sv": true, "da": true,
127
+ "es-es": true, "en-us": true, "en-gb": true, "fr-fr": true, "fr-ca": true,
128
+ }
129
+
130
+ func mapPageLocale(raw string) string {
131
+ u, err := url.Parse(raw)
132
+ if err != nil {
133
+ return "en"
134
+ }
135
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
136
+ if len(parts) > 0 {
137
+ first := strings.ToLower(parts[0])
138
+ if knownSiteLocales[first] {
139
+ if strings.HasPrefix(first, "en") {
140
+ return "en"
141
+ }
142
+ if strings.HasPrefix(first, "zh") {
143
+ return "zh"
144
+ }
145
+ if strings.HasPrefix(first, "es") {
146
+ return "es"
147
+ }
148
+ if strings.HasPrefix(first, "fr") {
149
+ return "fr"
150
+ }
151
+ if strings.HasPrefix(first, "pt") {
152
+ return "pt"
153
+ }
154
+ return first
155
+ }
156
+ }
157
+ return "en"
158
+ }
159
+
160
+ func MapSitePage(input MapSiteInput) (map[string]any, error) {
161
+ if input.Limit == 0 && !input.limitProvided {
162
+ input.Limit = 30
163
+ }
164
+ if input.Limit < 1 || input.Limit > 100 {
165
+ return nil, dataError("invalid_limit", "map_site limit must be 1-100.")
166
+ }
167
+ if input.Offset < 0 || input.Offset > 100000 {
168
+ return nil, dataError("invalid_offset", "map_site offset must be nonnegative.")
169
+ }
170
+ if input.Language == "" {
171
+ input.Language = "en"
172
+ }
173
+ input.Language = strings.ToLower(strings.TrimSpace(input.Language))
174
+ if input.PathPrefix != "" && !strings.HasPrefix(input.PathPrefix, "/") {
175
+ input.PathPrefix = "/" + input.PathPrefix
176
+ }
177
+ if err := validateSourceURL(input.URL); err != nil {
178
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
179
+ }
180
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
181
+ defer cancel()
182
+ links, err := rawMappedLinks(ctx, input.URL)
183
+ if err != nil {
184
+ return nil, err
185
+ }
186
+ type located struct {
187
+ entry map[string]any
188
+ score int
189
+ path string
190
+ }
191
+ var matched []located
192
+ seen := map[string]bool{}
193
+ terms := queryTerms(input.Query)
194
+ for _, v := range links {
195
+ item := map[string]any{}
196
+ if s, ok := v.(string); ok {
197
+ item["url"] = s
198
+ } else {
199
+ item = mapField(v)
200
+ }
201
+ raw := stringField(item["url"])
202
+ if raw == "" || seen[raw] {
203
+ continue
204
+ }
205
+ seen[raw] = true
206
+ u, e := url.Parse(raw)
207
+ if e != nil || u.Host == "" {
208
+ continue
209
+ }
210
+ if input.Language != "all" && mapPageLocale(raw) != input.Language {
211
+ continue
212
+ }
213
+ if input.PathPrefix != "" && !strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(input.PathPrefix)) {
214
+ continue
215
+ }
216
+ name := stringField(item["title"])
217
+ desc := stringField(item["description"])
218
+ score := termScore(u.Path+" "+name, terms)*3 + termScore(desc, terms)
219
+ if len(terms) > 0 && score == 0 {
220
+ continue
221
+ }
222
+ clean := map[string]any{"url": raw}
223
+ if name != "" {
224
+ clean["title"] = boundedSiteText(name, 140)
225
+ }
226
+ if desc != "" {
227
+ clean["description"] = boundedSiteText(desc, 230)
228
+ }
229
+ matched = append(matched, located{entry: clean, score: score, path: u.Path})
230
+ }
231
+ sort.SliceStable(matched, func(i, j int) bool {
232
+ if len(terms) > 0 && matched[i].score != matched[j].score {
233
+ return matched[i].score > matched[j].score
234
+ }
235
+ if depthI, depthJ := strings.Count(matched[i].path, "/"), strings.Count(matched[j].path, "/"); depthI != depthJ {
236
+ return depthI < depthJ
237
+ }
238
+ return matched[i].path < matched[j].path
239
+ })
240
+ start := input.Offset
241
+ if start > len(matched) {
242
+ start = len(matched)
243
+ }
244
+ end := start + input.Limit
245
+ if end > len(matched) {
246
+ end = len(matched)
247
+ }
248
+ page := make([]map[string]any, 0, end-start)
249
+ for _, entry := range matched[start:end] {
250
+ page = append(page, entry.entry)
251
+ }
252
+ output := map[string]any{
253
+ "url": input.URL, "total_discovered": len(links),
254
+ "total_matching": len(matched), "links": page,
255
+ "count": len(page), "offset": start, "limit": input.Limit,
256
+ "language": input.Language, "provider_page_ceiling": 5000,
257
+ "may_be_incomplete": len(links) >= 5000,
258
+ "guidance": "Use next to request another bounded page, or set query/path_prefix/language to narrow results. Language=all includes translated pages.",
259
+ }
260
+ if end < len(matched) {
261
+ output["next"] = map[string]any{"tool": "map_site", "arguments": MapSiteInput{
262
+ URL: input.URL, Query: input.Query, Limit: input.Limit, Offset: end,
263
+ PathPrefix: input.PathPrefix, Language: input.Language,
264
+ }}
265
+ }
266
+ return output, nil
267
+ }
268
+
269
+ func boundedSiteText(value string, max int) string {
270
+ value = strings.Join(strings.Fields(value), " ")
271
+ if len(value) > max {
272
+ return value[:max] + "…"
273
+ }
274
+ return value
275
+ }
@@ -252,57 +252,27 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
252
252
  }
253
253
 
254
254
  func MapSite(rawURL string) (string, error) {
255
- if err := validateSourceURL(rawURL); err != nil {
255
+ data, err := MapSitePage(MapSiteInput{URL: rawURL})
256
+ if err != nil {
256
257
  return "", err
257
258
  }
258
- apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
259
- if apiKey == "" {
260
- return "", missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
259
+ parts := []string{
260
+ fmt.Sprintf("total urls found: %v", data["total_discovered"]),
261
+ fmt.Sprintf("matching pages: %v; displayed: %v (offset %v)", data["total_matching"], data["count"], data["offset"]),
262
+ "",
261
263
  }
262
-
263
- requestBody := map[string]any{
264
- "url": rawURL,
265
- "sitemap": "include",
266
- "includeSubdomains": true,
267
- "ignoreQueryParameters": true,
268
- "limit": 5000,
269
- }
270
-
271
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
272
- defer cancel()
273
- body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
274
- "Authorization": "Bearer " + apiKey,
275
- "Content-Type": "application/json",
276
- }, requestBody)
277
- if err != nil {
278
- return "", fmt.Errorf("Error mapping website: %v", err)
279
- }
280
-
281
- var parsed map[string]any
282
- if err := json.Unmarshal(body, &parsed); err != nil {
283
- return "", fmt.Errorf("Error mapping website: %v", err)
284
- }
285
- if success, _ := parsed["success"].(bool); !success {
286
- return "", fmt.Errorf("Error mapping website: Mapping failed for %s: %v", rawURL, parsed["error"])
287
- }
288
-
289
- linksAny, _ := parsed["links"].([]any)
290
- parts := []string{fmt.Sprintf("total urls found: %d", len(linksAny)), ""}
291
- for _, item := range linksAny {
292
- switch link := item.(type) {
293
- case string:
294
- parts = append(parts, "- "+link, "")
295
- case map[string]any:
296
- parts = append(parts, "- "+stringValue(link["url"]))
297
- if v := stringValue(link["title"]); v != "" {
298
- parts = append(parts, "- "+v)
299
- }
300
- if v := stringValue(link["description"]); v != "" {
301
- parts = append(parts, "- "+v)
302
- }
303
- parts = append(parts, "")
264
+ for _, entry := range data["links"].([]map[string]any) {
265
+ parts = append(parts, "- "+stringField(entry["url"]))
266
+ if title := stringField(entry["title"]); title != "" {
267
+ parts = append(parts, " - "+title)
268
+ }
269
+ if desc := stringField(entry["description"]); desc != "" {
270
+ parts = append(parts, " - "+desc)
304
271
  }
305
272
  }
273
+ if data["next"] != nil {
274
+ parts = append(parts, "", "More pages available; use --offset with webctx map-site for the next page.")
275
+ }
306
276
  return strings.Join(parts, "\n"), nil
307
277
  }
308
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
5
5
  "license": "MIT",
6
6
  "author": "amxv",
@@ -66,6 +66,33 @@ func TestDataToolErrorsAreActionableStructuredMCPResults(t *testing.T) {
66
66
  }
67
67
  }
68
68
 
69
+ func TestAuditMCPRejectsZeroLimitAndInvalidSourceURLsWithoutExceptions(t *testing.T) {
70
+ t.Setenv("ORIGO_API_KEY", "secret")
71
+ for _, tt := range []struct {
72
+ name string
73
+ args map[string]any
74
+ code string
75
+ }{
76
+ {"research", map[string]any{"mode": "catalogue", "view": "sources", "limit": 0}, "invalid_limit"},
77
+ {"map_site", map[string]any{"url": "https://docs.firecrawl.dev", "limit": 0}, "invalid_limit"},
78
+ {"read_link", map[string]any{"url": "file:///etc/passwd", "question": "summarize"}, "invalid_url"},
79
+ {"map_site", map[string]any{"url": "file:///etc/passwd"}, "invalid_url"},
80
+ } {
81
+ reply := toolResponse(t, tt.name, tt.args, 990)
82
+ if reply["isError"] == true {
83
+ t.Fatalf("%s surfaced a host error: %+v", tt.name, reply)
84
+ }
85
+ data, ok := reply["structuredContent"].(map[string]any)
86
+ if !ok || data["ok"] != false {
87
+ t.Fatalf("%s did not return a structured error: %+v", tt.name, reply)
88
+ }
89
+ failure := data["error"].(map[string]any)
90
+ if failure["code"] != tt.code {
91
+ t.Fatalf("%s expected %s, got %+v", tt.name, tt.code, failure)
92
+ }
93
+ }
94
+ }
95
+
69
96
  func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
70
97
  t.Setenv("ORIGO_API_KEY", "secret")
71
98
  t.Setenv("FIRECRAWL_API_KEY", "test")
@@ -36,22 +36,21 @@ func makeServer() *mcp.Server {
36
36
  }, func(_ context.Context, _ *mcp.CallToolRequest, input readLinkInput) (*mcp.CallToolResult, any, error) {
37
37
  result, err := retrieval.ReadLinkFocused(input.URL, input.Question)
38
38
  if err != nil {
39
- return nil, nil, err
39
+ code := "read_failed"
40
+ if strings.Contains(err.Error(), "absolute HTTP(S)") || strings.Contains(err.Error(), "readable URL") {
41
+ code = "invalid_url"
42
+ }
43
+ return dataMCPResult(nil, &retrieval.DataError{Code: code, Message: err.Error()})
40
44
  }
41
45
  return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
42
46
  })
43
- mcp.AddTool(s, &mcp.Tool{
47
+ s.AddTool(&mcp.Tool{
44
48
  Name: "map_site",
45
49
  Title: "Map site",
46
- Description: "Discover URLs on a site using Firecrawl's sitemap-based map API. Does not perform web search.",
50
+ Description: "Discover website pages in bounded, paginated groups (30 by default). Filter by topic, path prefix and language (en by default, all for translations). Returns structured titles, descriptions, counts, and copy-ready next-page arguments.",
47
51
  Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
48
- }, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
49
- result, err := retrieval.MapSite(input.URL)
50
- if err != nil {
51
- return nil, nil, err
52
- }
53
- return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
54
- })
52
+ InputSchema: mapSiteSchema(),
53
+ }, dataHandler(retrieval.MapSitePage))
55
54
  s.AddTool(&mcp.Tool{
56
55
  Name: "research",
57
56
  Title: "Find data sources",
@@ -125,6 +124,14 @@ func executeDataSchema() *jsonschema.Schema {
125
124
  return schema
126
125
  }
127
126
 
127
+ func mapSiteSchema() *jsonschema.Schema {
128
+ schema, err := jsonschema.For[retrieval.MapInput](nil)
129
+ if err != nil {
130
+ panic(err)
131
+ }
132
+ return schema
133
+ }
134
+
128
135
  // Manual validation keeps data errors in successful MCP tool responses rather
129
136
  // than surfacing them as client-side INVALID_ARGUMENT tool exceptions.
130
137
  func dataHandler[In any](fn func(In) (map[string]any, error)) mcp.ToolHandler {
@@ -7,8 +7,10 @@ type InspectInput = app.InspectDataInput
7
7
  type ExecuteInput = app.ExecuteDataInput
8
8
  type DataCall = app.DataCall
9
9
  type DataError = app.DataError
10
+ type MapInput = app.MapSiteInput
10
11
 
11
12
  func Research(input ResearchInput) (map[string]any, error) { return app.ResearchData(input) }
12
13
  func Inspect(input InspectInput) (map[string]any, error) { return app.InspectData(input) }
13
14
  func Execute(input ExecuteInput) (map[string]any, error) { return app.ExecuteData(input) }
14
15
  func DataErrorResult(err error) map[string]any { return app.DataErrorResult(err) }
16
+ func MapSitePage(input MapInput) (map[string]any, error) { return app.MapSitePage(input) }