webctx 0.2.5 → 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.
@@ -87,6 +87,10 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
87
87
  if err != nil {
88
88
  return "", err
89
89
  }
90
+ primary = copyableCurlExamples(primary)
91
+ if answer, ok := directlyAnsweredMetadata(rawURL, question, primary); ok {
92
+ return answer, nil
93
+ }
90
94
 
91
95
  // Combine on-page links and authoritative Markdown index links instead of
92
96
  // filling the entire budget from whichever links happen to appear first.
@@ -94,7 +98,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
94
98
  // SQLite, webhook, and API references over incidental alarm/concept pages.
95
99
  links := researchCandidates(rawURL, primary, question, maxRelatedSources)
96
100
  structuredDone := make(chan string, 1)
97
- if strings.Contains(primary, "**Markdown source:**") {
101
+ if strings.Contains(primary, "**Markdown source:**") || !asksForAPIContracts(question) {
98
102
  structuredDone <- ""
99
103
  } else {
100
104
  go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
@@ -109,7 +113,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
109
113
  "**Source:** " + rawURL,
110
114
  "",
111
115
  }
112
- primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
116
+ primaryBudget := focusedPrimaryBudget
117
+ if asksForAPIContracts(question) {
118
+ primaryBudget = 5300
119
+ }
120
+ primaryExcerpt := focusExcerpt(primary, question, primaryBudget)
113
121
  parts = append(parts, primaryExcerpt)
114
122
  successful := 1
115
123
  relatedSuccessful := 0
@@ -124,7 +132,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
124
132
  relatedSuccessful++
125
133
  seenURLs[source.url] = true
126
134
  allRetrieved += "\n" + source.content
127
- excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
135
+ relatedBudget := relatedExcerptBudget(source.url)
136
+ if asksForAPIContracts(question) && relatedBudget > 3000 {
137
+ relatedBudget = 3000
138
+ }
139
+ excerpt := focusExcerpt(copyableCurlExamples(source.content), question, relatedBudget)
128
140
  outputEvidence += "\n" + excerpt
129
141
  parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
130
142
  }
@@ -172,7 +184,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
172
184
  successful++
173
185
  relatedSuccessful++
174
186
  allRetrieved += "\n" + source.content
175
- excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
187
+ followupBudget := relatedExcerptBudget(source.url)
188
+ if asksForAPIContracts(question) && followupBudget > 3000 {
189
+ followupBudget = 3000
190
+ }
191
+ excerpt := focusExcerpt(copyableCurlExamples(source.content), question, followupBudget)
176
192
  outputEvidence += "\n" + excerpt
177
193
  parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
178
194
  }
@@ -181,7 +197,10 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
181
197
  if contract := <-openAPIDone; contract.Content != "" {
182
198
  successful++
183
199
  outputEvidence += "\n" + contract.Content
184
- parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
200
+ // Put exact REST contracts first so coding agents encounter the
201
+ // implementation-ready operations before lengthy source excerpts.
202
+ front := []string{"## REST API contract", "**Source:** " + contract.URL, "", contract.Content, ""}
203
+ parts = append(append(append([]string(nil), parts[:4]...), front...), parts[4:]...)
185
204
  }
186
205
  }
187
206
  if structured := <-structuredDone; structured != "" {
@@ -0,0 +1,30 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "regexp"
6
+ "strings"
7
+ )
8
+
9
+ var githubLicenseField = regexp.MustCompile(`(?mi)^license:\s*["']?([A-Za-z0-9.+-]+)["']?\s*$`)
10
+ var repositoryLicenseURL = regexp.MustCompile(`https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/blob/[A-Za-z0-9_.-]+/(?:LICENSE|COPYING)(?:\.md|\.txt)?`)
11
+
12
+ func directlyAnsweredMetadata(rawURL, question, markdown string) (string, bool) {
13
+ q := strings.ToLower(question)
14
+ if !strings.Contains(q, "license") || !strings.Contains(strings.ToLower(rawURL), "github.com/") {
15
+ return "", false
16
+ }
17
+ match := githubLicenseField.FindStringSubmatch(markdown)
18
+ if len(match) != 2 {
19
+ return "", false
20
+ }
21
+ value := match[1]
22
+ evidenceURL := rawURL
23
+ if source := repositoryLicenseURL.FindString(markdown); source != "" {
24
+ evidenceURL = source
25
+ }
26
+ return fmt.Sprintf(
27
+ "## Answer\n\nThe repository lists its license as **%s**.\n\n**Source:** %s\n\n**Evidence:** GitHub repository metadata lists `license: %q`.\n",
28
+ value, evidenceURL, value,
29
+ ), true
30
+ }
@@ -0,0 +1,48 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "regexp"
6
+ "strings"
7
+ )
8
+
9
+ var trailingPayloadSlash = regexp.MustCompile("(?m)\\\\[ \\t]*(\\r?\\n)")
10
+
11
+ // Some source scrapers leave JavaScript-style line-continuation slashes
12
+ // inside shell-quoted JSON. Those slashes make the published cURL request
13
+ // uncopyable. Repair only payloads that are initially invalid JSON and become
14
+ // valid JSON after removing those continuation markers. Label every repair.
15
+ // The original source URL remains in the surrounding cited context.
16
+ func copyableCurlExamples(markdown string) string {
17
+ parts := strings.Split(markdown, backtickFence)
18
+ if len(parts) < 3 {
19
+ return markdown
20
+ }
21
+ for i := 1; i < len(parts); i += 2 {
22
+ code := parts[i]
23
+ if !strings.Contains(code, "curl ") {
24
+ continue
25
+ }
26
+ start := strings.Index(code, "-d '{")
27
+ if start < 0 {
28
+ continue
29
+ }
30
+ bodyStart := start + len("-d '")
31
+ endRel := strings.LastIndex(code[bodyStart:], "}'")
32
+ if endRel < 0 {
33
+ continue
34
+ }
35
+ bodyEnd := bodyStart + endRel + 1
36
+ original := code[bodyStart:bodyEnd]
37
+ if json.Valid([]byte(original)) {
38
+ continue
39
+ }
40
+ fixed := trailingPayloadSlash.ReplaceAllString(original, "$1")
41
+ if fixed == original || !json.Valid([]byte(fixed)) {
42
+ continue
43
+ }
44
+ parts[i] = code[:bodyStart] + fixed + code[bodyEnd:]
45
+ parts[i-1] += "\n\n*[Copy-ready correction: removed stray line-continuation backslashes inside the published JSON payload; see the cited URL for the original.]*\n"
46
+ }
47
+ return strings.Join(parts, backtickFence)
48
+ }
@@ -0,0 +1,57 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestMalformedAlexandriaJSONBecomesCopyable(t *testing.T) {
10
+ source := "# REST example\n" + backtickFence + "bash\n" +
11
+ "curl https://api.firecrawl.dev/v2/search \\\n" +
12
+ " -H 'Content-Type: application/json' \\\n" +
13
+ " -d '{\n \"query\": \"podcast conversations\",\n \"sources\": [\\\n \"web\",\\\n \"alexandria\"\\\n ],\n \"limit\": 2\n}'\n" +
14
+ backtickFence
15
+ got := copyableCurlExamples(source)
16
+ if !strings.Contains(got, "Copy-ready correction") {
17
+ t.Fatalf("lost truthful correction label: %s", got)
18
+ }
19
+ start := strings.Index(got, "-d '{")
20
+ if start < 0 {
21
+ t.Fatalf("lost source cURL: %s", got)
22
+ }
23
+ bodyStart := start + len("-d '")
24
+ bodyEnd := bodyStart + strings.LastIndex(got[bodyStart:], "}'") + 1
25
+ if !json.Valid([]byte(got[bodyStart:bodyEnd])) {
26
+ t.Fatalf("JSON request still uncopyable: %s", got[bodyStart:bodyEnd])
27
+ }
28
+ if !strings.Contains(got, " -H 'Content-Type: application/json' \\") {
29
+ t.Fatalf("source shell continuations should be preserved: %s", got)
30
+ }
31
+ if strings.Contains(got, "\"sources\": [\\") {
32
+ t.Fatalf("malformed JSON continuation not repaired: %s", got)
33
+ }
34
+ if copyableCurlExamples(got) != got {
35
+ t.Fatalf("correction should be idempotent")
36
+ }
37
+ }
38
+
39
+ func TestCorrectSourceExamplesAreNeverRewritten(t *testing.T) {
40
+ source := backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
41
+ if got := copyableCurlExamples(source); got != source {
42
+ t.Fatalf("valid published snippet was modified: %s", got)
43
+ }
44
+ }
45
+
46
+ func TestRESTContractsLeadAndExcerptsStayConcise(t *testing.T) {
47
+ if focusedPrimaryBudget <= 5300 {
48
+ t.Fatal("REST excerpt budget should be narrower than normal focused context")
49
+ }
50
+ source := "# Official API docs\n" + strings.Repeat("Large unrelated introduction about navigation.\n", 700) +
51
+ "\n## REST API endpoint example\n" +
52
+ backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
53
+ got := focusExcerpt(source, "What are the REST API endpoints and exact request examples?", 5300)
54
+ if len(got) > 6000 || !strings.Contains(got, "/v2/search") {
55
+ t.Fatalf("REST excerpt did not prioritize complete example under budget: len=%d", len(got))
56
+ }
57
+ }
@@ -0,0 +1,256 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "net/http"
10
+ "net/url"
11
+ "os"
12
+ "sort"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "golang.org/x/sync/singleflight"
18
+ )
19
+
20
+ // MapSiteInput retrieves an agent-sized page rather than dumping thousands
21
+ // of URLs. An omitted language prefers English; "all" allows all locales.
22
+ type MapSiteInput struct {
23
+ URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
24
+ Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
25
+ Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
26
+ Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
27
+ PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
28
+ Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
29
+ }
30
+
31
+ type siteMapCacheValue struct {
32
+ links []any
33
+ expires time.Time
34
+ }
35
+
36
+ var siteMapCache = struct {
37
+ sync.Mutex
38
+ items map[string]siteMapCacheValue
39
+ }{items: map[string]siteMapCacheValue{}}
40
+ var siteMapFlight singleflight.Group
41
+
42
+ func rawMappedLinks(ctx context.Context, rawURL string) ([]any, error) {
43
+ if err := validateSourceURL(rawURL); err != nil {
44
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
45
+ }
46
+ credential := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
47
+ if credential == "" {
48
+ return nil, missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
49
+ }
50
+ digest := sha256.Sum256([]byte(credential + "\x00" + rawURL))
51
+ cacheKey := hex.EncodeToString(digest[:])
52
+ useCache := !strings.HasPrefix(credential, "test")
53
+ if useCache {
54
+ siteMapCache.Lock()
55
+ cached, ok := siteMapCache.items[cacheKey]
56
+ siteMapCache.Unlock()
57
+ if ok && time.Now().Before(cached.expires) {
58
+ return cached.links, nil
59
+ }
60
+ }
61
+ fetch := func() (any, error) {
62
+ request := map[string]any{
63
+ "url": rawURL, "sitemap": "include",
64
+ "includeSubdomains": true, "ignoreQueryParameters": true, "limit": 5000,
65
+ }
66
+ body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
67
+ "Authorization": "Bearer " + credential, "Content-Type": "application/json",
68
+ }, request)
69
+ if err != nil {
70
+ return nil, fmt.Errorf("Error mapping website: %w", err)
71
+ }
72
+ var result map[string]any
73
+ if err := json.Unmarshal(body, &result); err != nil {
74
+ return nil, fmt.Errorf("Error decoding map: %w", err)
75
+ }
76
+ if result["success"] != true {
77
+ return nil, dataError("map_failed", fmt.Sprintf("The source could not be mapped: %v", result["error"]))
78
+ }
79
+ links := listField(result["links"])
80
+ if useCache {
81
+ siteMapCache.Lock()
82
+ if len(siteMapCache.items) > 50 {
83
+ siteMapCache.items = map[string]siteMapCacheValue{}
84
+ }
85
+ siteMapCache.items[cacheKey] = siteMapCacheValue{links: links, expires: time.Now().Add(3 * time.Minute)}
86
+ siteMapCache.Unlock()
87
+ }
88
+ return links, nil
89
+ }
90
+ if !useCache {
91
+ data, err := fetch()
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ return data.([]any), nil
96
+ }
97
+ data, err, _ := siteMapFlight.Do(cacheKey, fetch)
98
+ if err != nil {
99
+ return nil, err
100
+ }
101
+ return data.([]any), nil
102
+ }
103
+
104
+ var knownSiteLocales = map[string]bool{
105
+ "en": true, "fr": true, "es": true, "de": true, "ja": true, "zh": true, "zh-cn": true, "zh-tw": true,
106
+ "pt": true, "pt-br": true, "it": true, "ko": true, "ru": true, "ar": true, "hi": true, "id": true,
107
+ "nl": true, "pl": true, "tr": true, "vi": true, "th": true, "sv": true, "da": true,
108
+ "es-es": true, "en-us": true, "en-gb": true, "fr-fr": true, "fr-ca": true,
109
+ }
110
+
111
+ func mapPageLocale(raw string) string {
112
+ u, err := url.Parse(raw)
113
+ if err != nil {
114
+ return "en"
115
+ }
116
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
117
+ if len(parts) > 0 {
118
+ first := strings.ToLower(parts[0])
119
+ if knownSiteLocales[first] {
120
+ if strings.HasPrefix(first, "en") {
121
+ return "en"
122
+ }
123
+ if strings.HasPrefix(first, "zh") {
124
+ return "zh"
125
+ }
126
+ if strings.HasPrefix(first, "es") {
127
+ return "es"
128
+ }
129
+ if strings.HasPrefix(first, "fr") {
130
+ return "fr"
131
+ }
132
+ if strings.HasPrefix(first, "pt") {
133
+ return "pt"
134
+ }
135
+ return first
136
+ }
137
+ }
138
+ return "en"
139
+ }
140
+
141
+ func MapSitePage(input MapSiteInput) (map[string]any, error) {
142
+ if input.Limit == 0 {
143
+ input.Limit = 30
144
+ }
145
+ if input.Limit < 1 || input.Limit > 100 {
146
+ return nil, dataError("invalid_limit", "map_site limit must be 1-100.")
147
+ }
148
+ if input.Offset < 0 || input.Offset > 100000 {
149
+ return nil, dataError("invalid_offset", "map_site offset must be nonnegative.")
150
+ }
151
+ if input.Language == "" {
152
+ input.Language = "en"
153
+ }
154
+ input.Language = strings.ToLower(strings.TrimSpace(input.Language))
155
+ if input.PathPrefix != "" && !strings.HasPrefix(input.PathPrefix, "/") {
156
+ input.PathPrefix = "/" + input.PathPrefix
157
+ }
158
+ if err := validateSourceURL(input.URL); err != nil {
159
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
160
+ }
161
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
162
+ defer cancel()
163
+ links, err := rawMappedLinks(ctx, input.URL)
164
+ if err != nil {
165
+ return nil, err
166
+ }
167
+ type located struct {
168
+ entry map[string]any
169
+ score int
170
+ path string
171
+ }
172
+ var matched []located
173
+ seen := map[string]bool{}
174
+ terms := queryTerms(input.Query)
175
+ for _, v := range links {
176
+ item := map[string]any{}
177
+ if s, ok := v.(string); ok {
178
+ item["url"] = s
179
+ } else {
180
+ item = mapField(v)
181
+ }
182
+ raw := stringField(item["url"])
183
+ if raw == "" || seen[raw] {
184
+ continue
185
+ }
186
+ seen[raw] = true
187
+ u, e := url.Parse(raw)
188
+ if e != nil || u.Host == "" {
189
+ continue
190
+ }
191
+ if input.Language != "all" && mapPageLocale(raw) != input.Language {
192
+ continue
193
+ }
194
+ if input.PathPrefix != "" && !strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(input.PathPrefix)) {
195
+ continue
196
+ }
197
+ name := stringField(item["title"])
198
+ desc := stringField(item["description"])
199
+ score := termScore(u.Path+" "+name, terms)*3 + termScore(desc, terms)
200
+ if len(terms) > 0 && score == 0 {
201
+ continue
202
+ }
203
+ clean := map[string]any{"url": raw}
204
+ if name != "" {
205
+ clean["title"] = boundedSiteText(name, 140)
206
+ }
207
+ if desc != "" {
208
+ clean["description"] = boundedSiteText(desc, 230)
209
+ }
210
+ matched = append(matched, located{entry: clean, score: score, path: u.Path})
211
+ }
212
+ sort.SliceStable(matched, func(i, j int) bool {
213
+ if len(terms) > 0 && matched[i].score != matched[j].score {
214
+ return matched[i].score > matched[j].score
215
+ }
216
+ if depthI, depthJ := strings.Count(matched[i].path, "/"), strings.Count(matched[j].path, "/"); depthI != depthJ {
217
+ return depthI < depthJ
218
+ }
219
+ return matched[i].path < matched[j].path
220
+ })
221
+ start := input.Offset
222
+ if start > len(matched) {
223
+ start = len(matched)
224
+ }
225
+ end := start + input.Limit
226
+ if end > len(matched) {
227
+ end = len(matched)
228
+ }
229
+ page := make([]map[string]any, 0, end-start)
230
+ for _, entry := range matched[start:end] {
231
+ page = append(page, entry.entry)
232
+ }
233
+ output := map[string]any{
234
+ "url": input.URL, "total_discovered": len(links),
235
+ "total_matching": len(matched), "links": page,
236
+ "count": len(page), "offset": start, "limit": input.Limit,
237
+ "language": input.Language, "provider_page_ceiling": 5000,
238
+ "may_be_incomplete": len(links) >= 5000,
239
+ "guidance": "Use next to request another bounded page, or set query/path_prefix/language to narrow results. Language=all includes translated pages.",
240
+ }
241
+ if end < len(matched) {
242
+ output["next"] = map[string]any{"tool": "map_site", "arguments": MapSiteInput{
243
+ URL: input.URL, Query: input.Query, Limit: input.Limit, Offset: end,
244
+ PathPrefix: input.PathPrefix, Language: input.Language,
245
+ }}
246
+ }
247
+ return output, nil
248
+ }
249
+
250
+ func boundedSiteText(value string, max int) string {
251
+ value = strings.Join(strings.Fields(value), " ")
252
+ if len(value) > max {
253
+ return value[:max] + "…"
254
+ }
255
+ return value
256
+ }
@@ -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.5",
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",
@@ -49,26 +49,52 @@ func TestDataToolErrorsAreActionableStructuredMCPResults(t *testing.T) {
49
49
  {"research", map[string]any{"view": "invalid"}, "invalid_view"},
50
50
  {"inspect", map[string]any{"id": "not-a-valid-operation/"}, "invalid_operation"},
51
51
  {"execute", map[string]any{"calls": []any{}}, "invalid_calls"},
52
+ {"execute", map[string]any{"calls": []any{map[string]any{"id": "example/data/list"}}, "max_credits": 1}, "invalid_arguments"},
52
53
  } {
53
54
  response := toolResponse(t, tc.name, tc.input, 11)
54
- if response["isError"] != true {
55
- t.Fatalf("%s should return isError: %#v", tc.name, response)
55
+ if response["isError"] == true {
56
+ t.Fatalf("%s should return normal structured response, not an MCP error: %#v", tc.name, response)
56
57
  }
57
58
  output, ok := response["structuredContent"].(map[string]any)
58
59
  if !ok {
59
60
  t.Fatalf("%s did not preserve structuredContent: %#v", tc.name, response)
60
61
  }
61
62
  errorResult, ok := output["error"].(map[string]any)
62
- if !ok || errorResult["code"] != tc.code {
63
+ if !ok || errorResult["code"] != tc.code || output["ok"] != false {
63
64
  t.Fatalf("%s error should be %s: %#v", tc.name, tc.code, response)
64
65
  }
65
66
  }
66
67
  }
67
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
+
68
95
  func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
69
96
  t.Setenv("ORIGO_API_KEY", "secret")
70
97
  t.Setenv("FIRECRAWL_API_KEY", "test")
71
- t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
72
98
  original := http.DefaultClient
73
99
  t.Cleanup(func() { http.DefaultClient = original })
74
100
  var paidCount int
@@ -126,7 +152,7 @@ func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
126
152
  }
127
153
  result := toolResponse(t, "execute", map[string]any{"calls": []any{
128
154
  map[string]any{"id": "example/data/list", "inputs": map[string]any{"query": "forty two"}},
129
- }, "max_credits": 3}, 14)
155
+ }}, 14)
130
156
  final := result["structuredContent"].(map[string]any)
131
157
  if result["isError"] == true || final["credits_used"] != float64(2) || paidCount != 1 {
132
158
  t.Fatalf("paid query failed: %#v calls=%d", final, paidCount)