webctx 0.2.6 → 0.2.7

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.
@@ -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.7",
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,32 @@ 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
+ {"read_link", map[string]any{"url": "file:///etc/passwd", "question": "summarize"}, "invalid_url"},
78
+ {"map_site", map[string]any{"url": "file:///etc/passwd"}, "invalid_url"},
79
+ } {
80
+ reply := toolResponse(t, tt.name, tt.args, 990)
81
+ if reply["isError"] == true {
82
+ t.Fatalf("%s surfaced a host error: %+v", tt.name, reply)
83
+ }
84
+ data, ok := reply["structuredContent"].(map[string]any)
85
+ if !ok || data["ok"] != false {
86
+ t.Fatalf("%s did not return a structured error: %+v", tt.name, reply)
87
+ }
88
+ failure := data["error"].(map[string]any)
89
+ if failure["code"] != tt.code {
90
+ t.Fatalf("%s expected %s, got %+v", tt.name, tt.code, failure)
91
+ }
92
+ }
93
+ }
94
+
69
95
  func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
70
96
  t.Setenv("ORIGO_API_KEY", "secret")
71
97
  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) }