webctx 0.2.3 → 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,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) }