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.
- 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/internal/app/firecrawl_fallback.go +4 -14
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +122 -33
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_test.go +4 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +10 -0
- package/internal/app/tools.go +16 -7
- 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,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) }
|