webctx 0.2.4 → 0.2.5

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,40 @@
1
+ package app
2
+
3
+ // ResearchData discovers data sources and their available operations. Search
4
+ // and catalogue browsing are both free Firecrawl Alexandria operations.
5
+ type ResearchDataInput struct {
6
+ Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
7
+ Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
8
+ URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
9
+ Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
10
+ Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
11
+ Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
12
+ Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
13
+ View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
14
+ Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
15
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
16
+ Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
17
+ Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
18
+ }
19
+
20
+ // InspectDataInput returns the exact contract for a selected operation, or
21
+ // browses all operations of a source when passed just a source ID.
22
+ type InspectDataInput struct {
23
+ ID string `json:"id" jsonschema:"Source or operation ID returned by research, e.g. particle/podcasts/episodes/search"`
24
+ Include []string `json:"include,omitempty" jsonschema:"Contract sections: inputs, output, examples; all by default"`
25
+ Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original Firecrawl response"`
26
+ }
27
+
28
+ type DataCall struct {
29
+ ID string `json:"id" jsonschema:"Exact operation ID returned by research or inspect"`
30
+ Inputs map[string]any `json:"inputs,omitempty" jsonschema:"Provider-specific input fields copied from the inspected contract"`
31
+ }
32
+
33
+ // ExecuteDataInput supports up to ten calls in a single provider request.
34
+ // It validates each call against the free catalogue before using credits.
35
+ type ExecuteDataInput struct {
36
+ Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
37
+ MaxCredits int `json:"max_credits,omitempty" jsonschema:"Maximum predicted credits for this request; defaults to WEBCTX_ALEXANDRIA_MAX_CREDITS or 100"`
38
+ RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
39
+ Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
40
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.4",
4
- "description": "Pure Go web search and browsing CLI using Brave, Tavily, Exa, and Firecrawl",
3
+ "version": "0.2.5",
4
+ "description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
5
5
  "license": "MIT",
6
6
  "author": "amxv",
7
7
  "repository": {
@@ -0,0 +1,134 @@
1
+ package origo
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ type testDataRoundTripper func(*http.Request) (*http.Response, error)
12
+
13
+ func (f testDataRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
14
+
15
+ func toolResponse(t *testing.T, name string, args any, id int) map[string]any {
16
+ t.Helper()
17
+ command, err := json.Marshal(map[string]any{
18
+ "jsonrpc": "2.0", "id": id, "method": "tools/call",
19
+ "params": map[string]any{"name": name, "arguments": args},
20
+ })
21
+ if err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ response := request(t, "secret", string(command))
25
+ if response.Code != 200 {
26
+ t.Fatalf("MCP status %d: %s", response.Code, response.Body.String())
27
+ }
28
+ var decoded map[string]any
29
+ if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
30
+ t.Fatalf("MCP JSON decode: %v", err)
31
+ }
32
+ if decoded["error"] != nil {
33
+ t.Fatalf("MCP protocol error: %s", response.Body.String())
34
+ }
35
+ result, ok := decoded["result"].(map[string]any)
36
+ if !ok {
37
+ t.Fatalf("missing result: %s", response.Body.String())
38
+ }
39
+ return result
40
+ }
41
+
42
+ func TestDataToolErrorsAreActionableStructuredMCPResults(t *testing.T) {
43
+ t.Setenv("ORIGO_API_KEY", "secret")
44
+ for _, tc := range []struct {
45
+ name string
46
+ input map[string]any
47
+ code string
48
+ }{
49
+ {"research", map[string]any{"view": "invalid"}, "invalid_view"},
50
+ {"inspect", map[string]any{"id": "not-a-valid-operation/"}, "invalid_operation"},
51
+ {"execute", map[string]any{"calls": []any{}}, "invalid_calls"},
52
+ } {
53
+ response := toolResponse(t, tc.name, tc.input, 11)
54
+ if response["isError"] != true {
55
+ t.Fatalf("%s should return isError: %#v", tc.name, response)
56
+ }
57
+ output, ok := response["structuredContent"].(map[string]any)
58
+ if !ok {
59
+ t.Fatalf("%s did not preserve structuredContent: %#v", tc.name, response)
60
+ }
61
+ errorResult, ok := output["error"].(map[string]any)
62
+ if !ok || errorResult["code"] != tc.code {
63
+ t.Fatalf("%s error should be %s: %#v", tc.name, tc.code, response)
64
+ }
65
+ }
66
+ }
67
+
68
+ func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
69
+ t.Setenv("ORIGO_API_KEY", "secret")
70
+ t.Setenv("FIRECRAWL_API_KEY", "test")
71
+ t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
72
+ original := http.DefaultClient
73
+ t.Cleanup(func() { http.DefaultClient = original })
74
+ var paidCount int
75
+ http.DefaultClient = &http.Client{Transport: testDataRoundTripper(func(req *http.Request) (*http.Response, error) {
76
+ body, err := io.ReadAll(req.Body)
77
+ if err != nil {
78
+ return nil, err
79
+ }
80
+ var args map[string]any
81
+ if json.Unmarshal(body, &args) != nil {
82
+ t.Fatalf("bad upstream request: %s", body)
83
+ }
84
+ var data map[string]any
85
+ switch {
86
+ case req.URL.Path == "/v2/search":
87
+ data = map[string]any{"success": true, "data": map[string]any{"tools": []any{
88
+ map[string]any{"id": "example/data/list", "provider": "example", "capability": "data/list", "name": "List example data", "creditsCost": 2, "description": "Demo read-only data"},
89
+ }}}
90
+ case req.URL.Path == "/v2/scrape" && args["alexandria"] != nil:
91
+ switch args["alexandria"].(type) {
92
+ case map[string]any:
93
+ data = map[string]any{"success": true, "data": map[string]any{"alexandria": []any{
94
+ map[string]any{"provider": "firecrawl", "capability": "find-tools", "data": map[string]any{
95
+ "items": []any{map[string]any{
96
+ "id": "example/data/list", "provider": "example", "capability": "data/list", "name": "List example data",
97
+ "creditsCost": 2, "perRecord": false, "options": []any{map[string]any{"name": "query", "type": "string", "required": true}},
98
+ "response": map[string]any{"key": "data"},
99
+ }},
100
+ }},
101
+ }}}
102
+ case []any:
103
+ paidCount++
104
+ if req.Header.Get("x-request-id") == "" {
105
+ t.Fatal("paid call missing idempotency header")
106
+ }
107
+ data = map[string]any{"success": true, "data": map[string]any{"creditsCost": 2, "alexandria": []any{
108
+ map[string]any{"provider": "example", "capability": "data/list", "creditsCost": 2, "alexandriaId": "receipt-1", "data": map[string]any{"data": []any{map[string]any{"answer": 42}}}},
109
+ }}}
110
+ }
111
+ }
112
+ if data == nil {
113
+ t.Fatalf("unhandled upstream operation %s", req.URL)
114
+ }
115
+ b, _ := json.Marshal(data)
116
+ return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(string(b))), Header: make(http.Header), Request: req}, nil
117
+ })}
118
+ discovery := toolResponse(t, "research", map[string]any{"query": "example data"}, 12)
119
+ if discovery["isError"] == true || discovery["structuredContent"] == nil {
120
+ t.Fatalf("research failed: %#v", discovery)
121
+ }
122
+ contract := toolResponse(t, "inspect", map[string]any{"id": "example/data/list"}, 13)
123
+ structured := contract["structuredContent"].(map[string]any)
124
+ if structured["id"] != "example/data/list" || structured["next"] == nil {
125
+ t.Fatalf("inspect failed: %#v", structured)
126
+ }
127
+ result := toolResponse(t, "execute", map[string]any{"calls": []any{
128
+ map[string]any{"id": "example/data/list", "inputs": map[string]any{"query": "forty two"}},
129
+ }, "max_credits": 3}, 14)
130
+ final := result["structuredContent"].(map[string]any)
131
+ if result["isError"] == true || final["credits_used"] != float64(2) || paidCount != 1 {
132
+ t.Fatalf("paid query failed: %#v calls=%d", final, paidCount)
133
+ }
134
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "context"
7
7
  "crypto/sha256"
8
8
  "crypto/subtle"
9
+ "encoding/json"
9
10
  "net/http"
10
11
  "os"
11
12
  "strings"
@@ -49,9 +50,54 @@ func makeServer() *mcp.Server {
49
50
  }
50
51
  return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
51
52
  })
53
+ mcp.AddTool(s, &mcp.Tool{
54
+ Name: "research",
55
+ Title: "Find data sources",
56
+ Description: "Discover real-world information sources and their available operations. Search semantically, browse all sources, groups or operations, filter by websites/source IDs/categories, and paginate. Free; returns IDs and copy-ready inspect calls. Use read_link for regular documentation.",
57
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
58
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input retrieval.ResearchInput) (*mcp.CallToolResult, any, error) {
59
+ result, err := retrieval.Research(input)
60
+ return dataMCPResult(result, err)
61
+ })
62
+ mcp.AddTool(s, &mcp.Tool{
63
+ Name: "inspect",
64
+ Title: "Inspect a data source or operation",
65
+ Description: "Explore operations in a source, or inspect one operation's EXACT input types, required fields, constraints, output contract, pricing, and examples. Returns the next execute call with the exact input keys to fill. Free.",
66
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
67
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input retrieval.InspectInput) (*mcp.CallToolResult, any, error) {
68
+ result, err := retrieval.Inspect(input)
69
+ return dataMCPResult(result, err)
70
+ })
71
+ mcp.AddTool(s, &mcp.Tool{
72
+ Name: "execute",
73
+ Title: "Query a data source (uses credits)",
74
+ Description: "Execute 1-10 inspected structured-data operations using their exact source IDs and inputs. Charges Firecrawl Alexandria credits after server-side schema and credit-budget checks. Supports pagination/continuations, stable request_id for safe retries, and original provider JSON. No third-party terms are accepted automatically.",
75
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: func() *bool { v := false; return &v }()},
76
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input retrieval.ExecuteInput) (*mcp.CallToolResult, any, error) {
77
+ result, err := retrieval.Execute(input)
78
+ return dataMCPResult(result, err)
79
+ })
52
80
  return s
53
81
  }
54
82
 
83
+ func dataMCPResult(result map[string]any, err error) (*mcp.CallToolResult, any, error) {
84
+ failed := err != nil
85
+ if failed {
86
+ // Use the shared error serializer to preserve codes, corrective details
87
+ // and provider-term acceptance URLs as structured tool output.
88
+ result = retrieval.DataErrorResult(err)
89
+ }
90
+ bytes, marshalErr := json.MarshalIndent(result, "", " ")
91
+ if marshalErr != nil {
92
+ return nil, nil, marshalErr
93
+ }
94
+ return &mcp.CallToolResult{
95
+ Content: []mcp.Content{&mcp.TextContent{Text: string(bytes)}},
96
+ StructuredContent: result,
97
+ IsError: failed,
98
+ }, nil, nil
99
+ }
100
+
55
101
  var transport = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
56
102
  return makeServer()
57
103
  }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
@@ -47,21 +47,26 @@ func TestServerAuthentication(t *testing.T) {
47
47
  }
48
48
  }
49
49
 
50
- func TestExactlyTwoToolsAndNoSearch(t *testing.T) {
50
+ func TestFiveToolsWithProgressiveDataInterface(t *testing.T) {
51
51
  t.Setenv("ORIGO_API_KEY", "secret")
52
52
  response := request(t, "secret", `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
53
53
  if response.Code != http.StatusOK {
54
54
  t.Fatalf("tools/list failed: %d %s", response.Code, response.Body.String())
55
55
  }
56
56
  text := response.Body.String()
57
- if !strings.Contains(text, `"name":"read_link"`) || !strings.Contains(text, `"name":"map_site"`) || strings.Contains(text, `"name":"search"`) {
57
+ if !strings.Contains(text, `"name":"read_link"`) || !strings.Contains(text, `"name":"map_site"`) ||
58
+ !strings.Contains(text, `"name":"research"`) || !strings.Contains(text, `"name":"inspect"`) ||
59
+ !strings.Contains(text, `"name":"execute"`) || strings.Contains(text, `"name":"search"`) {
58
60
  t.Fatalf("unexpected tools: %s", text)
59
61
  }
60
- if count := strings.Count(text, `"name":`); count != 2 {
61
- t.Fatalf("expected exactly 2 tools, got %d: %s", count, text)
62
+ if count := strings.Count(text, `"name":`); count != 5 {
63
+ t.Fatalf("expected exactly 5 tools, got %d: %s", count, text)
62
64
  }
63
- if count := strings.Count(text, `"readOnlyHint":true`); count != 2 {
64
- t.Fatalf("expected both tools to declare read-only annotations, got %d: %s", count, text)
65
+ if count := strings.Count(text, `"readOnlyHint":true`); count != 4 {
66
+ t.Fatalf("expected the four free retrieval tools to be read-only, got %d: %s", count, text)
67
+ }
68
+ if count := strings.Count(text, `"readOnlyHint":false`); count != 1 {
69
+ t.Fatalf("expected only paid execute to indicate effects on billing, got %d: %s", count, text)
65
70
  }
66
71
  }
67
72
 
@@ -0,0 +1,13 @@
1
+ package retrieval
2
+
3
+ import "github.com/amxv/webctx/internal/app"
4
+
5
+ type ResearchInput = app.ResearchDataInput
6
+ type InspectInput = app.InspectDataInput
7
+ type ExecuteInput = app.ExecuteDataInput
8
+ type DataCall = app.DataCall
9
+
10
+ func Research(input ResearchInput) (map[string]any, error) { return app.ResearchData(input) }
11
+ func Inspect(input InspectInput) (map[string]any, error) { return app.InspectData(input) }
12
+ func Execute(input ExecuteInput) (map[string]any, error) { return app.ExecuteData(input) }
13
+ func DataErrorResult(err error) map[string]any { return app.DataErrorResult(err) }