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.
- package/README.md +1 -1
- package/internal/app/app.go +29 -1
- package/internal/app/data_audit_test.go +574 -0
- package/internal/app/data_client.go +193 -5
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +79 -6
- package/internal/app/data_execute.go +224 -64
- package/internal/app/data_inspect.go +20 -37
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +118 -0
- package/internal/app/data_research.go +189 -16
- package/internal/app/data_tools_test.go +2 -2
- package/internal/app/data_types.go +35 -12
- package/internal/app/research.go +4 -1
- package/internal/app/research_answers.go +30 -0
- package/internal/app/site_map_page.go +275 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +27 -0
- package/pkg/origo/server.go +17 -10
- package/pkg/retrieval/data.go +2 -0
|
@@ -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
|
+
}
|
package/internal/app/tools.go
CHANGED
|
@@ -252,57 +252,27 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
func MapSite(rawURL string) (string, error) {
|
|
255
|
-
|
|
255
|
+
data, err := MapSitePage(MapSiteInput{URL: rawURL})
|
|
256
|
+
if err != nil {
|
|
256
257
|
return "", err
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"
|
|
268
|
-
|
|
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
package/pkg/origo/data_test.go
CHANGED
|
@@ -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")
|
package/pkg/origo/server.go
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
+
s.AddTool(&mcp.Tool{
|
|
44
48
|
Name: "map_site",
|
|
45
49
|
Title: "Map site",
|
|
46
|
-
Description: "Discover
|
|
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
|
-
|
|
49
|
-
|
|
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 {
|
package/pkg/retrieval/data.go
CHANGED
|
@@ -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) }
|