webctx 0.2.4 → 0.2.6

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
+ {"execute", map[string]any{"calls": []any{map[string]any{"id": "example/data/list"}}, "max_credits": 1}, "invalid_arguments"},
53
+ } {
54
+ response := toolResponse(t, tc.name, tc.input, 11)
55
+ if response["isError"] == true {
56
+ t.Fatalf("%s should return normal structured response, not an MCP error: %#v", tc.name, response)
57
+ }
58
+ output, ok := response["structuredContent"].(map[string]any)
59
+ if !ok {
60
+ t.Fatalf("%s did not preserve structuredContent: %#v", tc.name, response)
61
+ }
62
+ errorResult, ok := output["error"].(map[string]any)
63
+ if !ok || errorResult["code"] != tc.code || output["ok"] != false {
64
+ t.Fatalf("%s error should be %s: %#v", tc.name, tc.code, response)
65
+ }
66
+ }
67
+ }
68
+
69
+ func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
70
+ t.Setenv("ORIGO_API_KEY", "secret")
71
+ t.Setenv("FIRECRAWL_API_KEY", "test")
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
+ }}, 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
+ }
@@ -3,14 +3,17 @@
3
3
  package origo
4
4
 
5
5
  import (
6
+ "bytes"
6
7
  "context"
7
8
  "crypto/sha256"
8
9
  "crypto/subtle"
10
+ "encoding/json"
9
11
  "net/http"
10
12
  "os"
11
13
  "strings"
12
14
 
13
15
  "github.com/amxv/webctx/pkg/retrieval"
16
+ "github.com/google/jsonschema-go/jsonschema"
14
17
  "github.com/modelcontextprotocol/go-sdk/mcp"
15
18
  )
16
19
 
@@ -49,9 +52,123 @@ func makeServer() *mcp.Server {
49
52
  }
50
53
  return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
51
54
  })
55
+ s.AddTool(&mcp.Tool{
56
+ Name: "research",
57
+ Title: "Find data sources",
58
+ 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.",
59
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
60
+ InputSchema: researchDataSchema(),
61
+ }, dataHandler(retrieval.Research))
62
+ s.AddTool(&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
+ InputSchema: inspectDataSchema(),
68
+ }, dataHandler(retrieval.Inspect))
69
+ s.AddTool(&mcp.Tool{
70
+ Name: "execute",
71
+ Title: "Query a data source (uses credits)",
72
+ Description: "Execute 1-10 inspected structured-data operations using exact operation IDs and inputs. Alexandria credits are charged automatically; the server enforces its configured 200-credit per-request ceiling. Actual credits are reported. Supports pagination, safe request_id retries and provider-native JSON. Does not accept third-party terms.",
73
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
74
+ InputSchema: executeDataSchema(),
75
+ }, dataHandler(retrieval.Execute))
52
76
  return s
53
77
  }
54
78
 
79
+ // Keep client-facing types narrow and self-describing. The SDK's default
80
+ // reflection makes optional slices nullable and string enums unconstrained;
81
+ // the catalogue operations support neither ambiguity.
82
+ func researchDataSchema() *jsonschema.Schema {
83
+ schema, err := jsonschema.For[retrieval.ResearchInput](nil)
84
+ if err != nil {
85
+ panic(err)
86
+ }
87
+ schema.Properties["mode"].Enum = []any{"ranked", "catalogue"}
88
+ schema.Properties["view"].Enum = []any{"sources", "groups", "operations"}
89
+ for _, key := range []string{"mode", "view"} {
90
+ schema.Properties[key].Type = "string"
91
+ schema.Properties[key].Types = nil
92
+ }
93
+ for _, key := range []string{"urls", "sources", "categories", "groups", "operations", "include"} {
94
+ p := schema.Properties[key]
95
+ p.Type = "array"
96
+ p.Types = nil
97
+ }
98
+ schema.Properties["include"].Items.Enum = []any{"inputs", "output", "examples"}
99
+ return schema
100
+ }
101
+
102
+ func inspectDataSchema() *jsonschema.Schema {
103
+ schema, err := jsonschema.For[retrieval.InspectInput](nil)
104
+ if err != nil {
105
+ panic(err)
106
+ }
107
+ p := schema.Properties["include"]
108
+ p.Type = "array"
109
+ p.Types = nil
110
+ p.Items.Enum = []any{"inputs", "output", "examples"}
111
+ return schema
112
+ }
113
+
114
+ func executeDataSchema() *jsonschema.Schema {
115
+ schema, err := jsonschema.For[retrieval.ExecuteInput](nil)
116
+ if err != nil {
117
+ panic(err)
118
+ }
119
+ countMin, countMax := 1, 10
120
+ p := schema.Properties["calls"]
121
+ p.Type = "array"
122
+ p.Types = nil
123
+ p.MinItems = &countMin
124
+ p.MaxItems = &countMax
125
+ return schema
126
+ }
127
+
128
+ // Manual validation keeps data errors in successful MCP tool responses rather
129
+ // than surfacing them as client-side INVALID_ARGUMENT tool exceptions.
130
+ func dataHandler[In any](fn func(In) (map[string]any, error)) mcp.ToolHandler {
131
+ return func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
132
+ var input In
133
+ args := req.Params.Arguments
134
+ if len(args) == 0 {
135
+ args = []byte("{}")
136
+ }
137
+ decoder := json.NewDecoder(bytes.NewReader(args))
138
+ decoder.DisallowUnknownFields()
139
+ if err := decoder.Decode(&input); err != nil {
140
+ response, _, e := dataMCPResult(nil, &retrieval.DataError{Code: "invalid_arguments", Message: err.Error()})
141
+ return response, e
142
+ }
143
+ result, err := fn(input)
144
+ response, _, e := dataMCPResult(result, err)
145
+ return response, e
146
+ }
147
+ }
148
+
149
+ func dataMCPResult(result map[string]any, err error) (*mcp.CallToolResult, any, error) {
150
+ failed := err != nil
151
+ if failed {
152
+ // Use the shared error serializer to preserve codes, corrective details
153
+ // and provider-term acceptance URLs as structured tool output.
154
+ result = retrieval.DataErrorResult(err)
155
+ result["ok"] = false
156
+ } else {
157
+ result["ok"] = true
158
+ }
159
+ bytes, marshalErr := json.MarshalIndent(result, "", " ")
160
+ if marshalErr != nil {
161
+ return nil, nil, marshalErr
162
+ }
163
+ return &mcp.CallToolResult{
164
+ Content: []mcp.Content{&mcp.TextContent{Text: string(bytes)}},
165
+ StructuredContent: result,
166
+ // Keep validation/provider failures in the structured result. Clients
167
+ // commonly convert isError=true to opaque INVALID_ARGUMENT exceptions.
168
+ IsError: false,
169
+ }, nil, nil
170
+ }
171
+
55
172
  var transport = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
56
173
  return makeServer()
57
174
  }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
@@ -1,6 +1,7 @@
1
1
  package origo
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "net/http"
5
6
  "net/http/httptest"
6
7
  "strings"
@@ -22,6 +23,57 @@ func request(t *testing.T, key, body string) *httptest.ResponseRecorder {
22
23
  return response
23
24
  }
24
25
 
26
+ func TestDataSchemasExposeExactEnumsAndNonNullableCalls(t *testing.T) {
27
+ t.Setenv("ORIGO_API_KEY", "secret")
28
+ result := request(t, "secret", `{"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}`)
29
+ if result.Code != 200 {
30
+ t.Fatalf("tools/list failed: %s", result.Body.String())
31
+ }
32
+ var decoded struct {
33
+ Result struct {
34
+ Tools []struct {
35
+ Name string `json:"name"`
36
+ InputSchema map[string]any `json:"inputSchema"`
37
+ Annotations map[string]any `json:"annotations"`
38
+ } `json:"tools"`
39
+ } `json:"result"`
40
+ }
41
+ if err := json.Unmarshal(result.Body.Bytes(), &decoded); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ schemas := map[string]map[string]any{}
45
+ for _, tool := range decoded.Result.Tools {
46
+ if tool.Annotations["readOnlyHint"] != true {
47
+ t.Fatalf("%s was not marked read-only", tool.Name)
48
+ }
49
+ if _, exists := tool.Annotations["openWorldHint"]; exists {
50
+ t.Fatalf("%s should omit openWorldHint", tool.Name)
51
+ }
52
+ schemas[tool.Name] = tool.InputSchema
53
+ }
54
+ if len(schemas) != 5 {
55
+ t.Fatalf("expected five tools, got %d", len(schemas))
56
+ }
57
+ props := func(name string) map[string]any { return schemas[name]["properties"].(map[string]any) }
58
+ mode := props("research")["mode"].(map[string]any)
59
+ view := props("research")["view"].(map[string]any)
60
+ if mode["type"] != "string" || len(mode["enum"].([]any)) != 2 ||
61
+ view["type"] != "string" || len(view["enum"].([]any)) != 3 {
62
+ t.Fatalf("research discovery schemas are not typed: mode=%#v view=%#v", mode, view)
63
+ }
64
+ incl := props("research")["include"].(map[string]any)
65
+ if incl["type"] != "array" || len(incl["items"].(map[string]any)["enum"].([]any)) != 3 {
66
+ t.Fatalf("include does not constrain allowed options: %#v", incl)
67
+ }
68
+ calls := props("execute")["calls"].(map[string]any)
69
+ if _, present := props("execute")["max_credits"]; present {
70
+ t.Fatal("execute still exposes credit-budget control")
71
+ }
72
+ if calls["type"] != "array" || calls["minItems"] != float64(1) || calls["maxItems"] != float64(10) {
73
+ t.Fatalf("execute calls must be non-null and 1–10 items: %#v", calls)
74
+ }
75
+ }
76
+
25
77
  func TestServerRequiresConfiguredKey(t *testing.T) {
26
78
  t.Setenv("ORIGO_API_KEY", "")
27
79
  response := request(t, "somekey", `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`)
@@ -47,21 +99,26 @@ func TestServerAuthentication(t *testing.T) {
47
99
  }
48
100
  }
49
101
 
50
- func TestExactlyTwoToolsAndNoSearch(t *testing.T) {
102
+ func TestFiveToolsWithProgressiveDataInterface(t *testing.T) {
51
103
  t.Setenv("ORIGO_API_KEY", "secret")
52
104
  response := request(t, "secret", `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
53
105
  if response.Code != http.StatusOK {
54
106
  t.Fatalf("tools/list failed: %d %s", response.Code, response.Body.String())
55
107
  }
56
108
  text := response.Body.String()
57
- if !strings.Contains(text, `"name":"read_link"`) || !strings.Contains(text, `"name":"map_site"`) || strings.Contains(text, `"name":"search"`) {
109
+ if !strings.Contains(text, `"name":"read_link"`) || !strings.Contains(text, `"name":"map_site"`) ||
110
+ !strings.Contains(text, `"name":"research"`) || !strings.Contains(text, `"name":"inspect"`) ||
111
+ !strings.Contains(text, `"name":"execute"`) || strings.Contains(text, `"name":"search"`) {
58
112
  t.Fatalf("unexpected tools: %s", text)
59
113
  }
60
- if count := strings.Count(text, `"name":`); count != 2 {
61
- t.Fatalf("expected exactly 2 tools, got %d: %s", count, text)
114
+ if count := strings.Count(text, `"name":`); count != 5 {
115
+ t.Fatalf("expected exactly 5 tools, got %d: %s", count, text)
116
+ }
117
+ if count := strings.Count(text, `"readOnlyHint":true`); count != 5 {
118
+ t.Fatalf("expected all five MCP tools to declare readOnlyHint=true, got %d: %s", count, text)
62
119
  }
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)
120
+ if strings.Contains(text, `"readOnlyHint":false`) || strings.Contains(text, `"openWorldHint"`) || strings.Contains(text, `"destructiveHint"`) {
121
+ t.Fatalf("unexpected non-read-only or open-world/destructive annotations: %s", text)
65
122
  }
66
123
  }
67
124
 
@@ -0,0 +1,14 @@
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
+ type DataError = app.DataError
10
+
11
+ func Research(input ResearchInput) (map[string]any, error) { return app.ResearchData(input) }
12
+ func Inspect(input InspectInput) (map[string]any, error) { return app.InspectData(input) }
13
+ func Execute(input ExecuteInput) (map[string]any, error) { return app.ExecuteData(input) }
14
+ func DataErrorResult(err error) map[string]any { return app.DataErrorResult(err) }