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.
- package/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +137 -0
- package/internal/app/data_client.go +220 -0
- package/internal/app/data_execute.go +424 -0
- package/internal/app/data_inspect.go +176 -0
- package/internal/app/data_research.go +323 -0
- package/internal/app/data_tools_test.go +336 -0
- package/internal/app/data_types.go +40 -0
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +46 -0
- package/pkg/origo/server_test.go +11 -6
- package/pkg/retrieval/data.go +13 -0
|
@@ -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
|
-
"description": "
|
|
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
|
+
}
|
package/pkg/origo/server.go
CHANGED
|
@@ -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})
|
package/pkg/origo/server_test.go
CHANGED
|
@@ -47,21 +47,26 @@ func TestServerAuthentication(t *testing.T) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
func
|
|
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"`) ||
|
|
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 !=
|
|
61
|
-
t.Fatalf("expected exactly
|
|
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 !=
|
|
64
|
-
t.Fatalf("expected
|
|
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) }
|