webctx 0.2.5 → 0.2.7
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/README.md +4 -4
- package/internal/app/app.go +34 -11
- package/internal/app/data_audit_test.go +433 -0
- package/internal/app/data_client.go +196 -14
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +185 -0
- package/internal/app/data_execute.go +231 -79
- package/internal/app/data_inspect.go +21 -40
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +83 -0
- package/internal/app/data_research.go +178 -6
- package/internal/app/data_tools_test.go +124 -16
- package/internal/app/data_types.go +38 -16
- package/internal/app/research.go +24 -5
- package/internal/app/research_answers.go +30 -0
- package/internal/app/research_copyable.go +48 -0
- package/internal/app/research_copyable_test.go +57 -0
- package/internal/app/site_map_page.go +256 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +31 -5
- package/pkg/origo/server.go +106 -28
- package/pkg/origo/server_test.go +56 -4
- package/pkg/retrieval/data.go +3 -0
package/pkg/origo/server.go
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
package origo
|
|
4
4
|
|
|
5
5
|
import (
|
|
6
|
+
"bytes"
|
|
6
7
|
"context"
|
|
7
8
|
"crypto/sha256"
|
|
8
9
|
"crypto/subtle"
|
|
@@ -12,6 +13,7 @@ import (
|
|
|
12
13
|
"strings"
|
|
13
14
|
|
|
14
15
|
"github.com/amxv/webctx/pkg/retrieval"
|
|
16
|
+
"github.com/google/jsonschema-go/jsonschema"
|
|
15
17
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
16
18
|
)
|
|
17
19
|
|
|
@@ -34,58 +36,132 @@ func makeServer() *mcp.Server {
|
|
|
34
36
|
}, func(_ context.Context, _ *mcp.CallToolRequest, input readLinkInput) (*mcp.CallToolResult, any, error) {
|
|
35
37
|
result, err := retrieval.ReadLinkFocused(input.URL, input.Question)
|
|
36
38
|
if err != nil {
|
|
37
|
-
|
|
39
|
+
code := "read_failed"
|
|
40
|
+
if strings.Contains(err.Error(), "absolute HTTP(S)") || strings.Contains(err.Error(), "readable URL") {
|
|
41
|
+
code = "invalid_url"
|
|
42
|
+
}
|
|
43
|
+
return dataMCPResult(nil, &retrieval.DataError{Code: code, Message: err.Error()})
|
|
38
44
|
}
|
|
39
45
|
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
|
|
40
46
|
})
|
|
41
|
-
|
|
47
|
+
s.AddTool(&mcp.Tool{
|
|
42
48
|
Name: "map_site",
|
|
43
49
|
Title: "Map site",
|
|
44
|
-
Description: "Discover
|
|
50
|
+
Description: "Discover website pages in bounded, paginated groups (30 by default). Filter by topic, path prefix and language (en by default, all for translations). Returns structured titles, descriptions, counts, and copy-ready next-page arguments.",
|
|
45
51
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return nil, nil, err
|
|
50
|
-
}
|
|
51
|
-
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
|
|
52
|
-
})
|
|
53
|
-
mcp.AddTool(s, &mcp.Tool{
|
|
52
|
+
InputSchema: mapSiteSchema(),
|
|
53
|
+
}, dataHandler(retrieval.MapSitePage))
|
|
54
|
+
s.AddTool(&mcp.Tool{
|
|
54
55
|
Name: "research",
|
|
55
56
|
Title: "Find data sources",
|
|
56
57
|
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
58
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
62
|
-
mcp.AddTool(s, &mcp.Tool{
|
|
59
|
+
InputSchema: researchDataSchema(),
|
|
60
|
+
}, dataHandler(retrieval.Research))
|
|
61
|
+
s.AddTool(&mcp.Tool{
|
|
63
62
|
Name: "inspect",
|
|
64
63
|
Title: "Inspect a data source or operation",
|
|
65
64
|
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
65
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
})
|
|
71
|
-
mcp.AddTool(s, &mcp.Tool{
|
|
66
|
+
InputSchema: inspectDataSchema(),
|
|
67
|
+
}, dataHandler(retrieval.Inspect))
|
|
68
|
+
s.AddTool(&mcp.Tool{
|
|
72
69
|
Name: "execute",
|
|
73
70
|
Title: "Query a data source (uses credits)",
|
|
74
|
-
Description: "Execute 1-10 inspected structured-data operations using
|
|
75
|
-
Annotations: &mcp.ToolAnnotations{ReadOnlyHint:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return dataMCPResult(result, err)
|
|
79
|
-
})
|
|
71
|
+
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.",
|
|
72
|
+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
73
|
+
InputSchema: executeDataSchema(),
|
|
74
|
+
}, dataHandler(retrieval.Execute))
|
|
80
75
|
return s
|
|
81
76
|
}
|
|
82
77
|
|
|
78
|
+
// Keep client-facing types narrow and self-describing. The SDK's default
|
|
79
|
+
// reflection makes optional slices nullable and string enums unconstrained;
|
|
80
|
+
// the catalogue operations support neither ambiguity.
|
|
81
|
+
func researchDataSchema() *jsonschema.Schema {
|
|
82
|
+
schema, err := jsonschema.For[retrieval.ResearchInput](nil)
|
|
83
|
+
if err != nil {
|
|
84
|
+
panic(err)
|
|
85
|
+
}
|
|
86
|
+
schema.Properties["mode"].Enum = []any{"ranked", "catalogue"}
|
|
87
|
+
schema.Properties["view"].Enum = []any{"sources", "groups", "operations"}
|
|
88
|
+
for _, key := range []string{"mode", "view"} {
|
|
89
|
+
schema.Properties[key].Type = "string"
|
|
90
|
+
schema.Properties[key].Types = nil
|
|
91
|
+
}
|
|
92
|
+
for _, key := range []string{"urls", "sources", "categories", "groups", "operations", "include"} {
|
|
93
|
+
p := schema.Properties[key]
|
|
94
|
+
p.Type = "array"
|
|
95
|
+
p.Types = nil
|
|
96
|
+
}
|
|
97
|
+
schema.Properties["include"].Items.Enum = []any{"inputs", "output", "examples"}
|
|
98
|
+
return schema
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func inspectDataSchema() *jsonschema.Schema {
|
|
102
|
+
schema, err := jsonschema.For[retrieval.InspectInput](nil)
|
|
103
|
+
if err != nil {
|
|
104
|
+
panic(err)
|
|
105
|
+
}
|
|
106
|
+
p := schema.Properties["include"]
|
|
107
|
+
p.Type = "array"
|
|
108
|
+
p.Types = nil
|
|
109
|
+
p.Items.Enum = []any{"inputs", "output", "examples"}
|
|
110
|
+
return schema
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func executeDataSchema() *jsonschema.Schema {
|
|
114
|
+
schema, err := jsonschema.For[retrieval.ExecuteInput](nil)
|
|
115
|
+
if err != nil {
|
|
116
|
+
panic(err)
|
|
117
|
+
}
|
|
118
|
+
countMin, countMax := 1, 10
|
|
119
|
+
p := schema.Properties["calls"]
|
|
120
|
+
p.Type = "array"
|
|
121
|
+
p.Types = nil
|
|
122
|
+
p.MinItems = &countMin
|
|
123
|
+
p.MaxItems = &countMax
|
|
124
|
+
return schema
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func mapSiteSchema() *jsonschema.Schema {
|
|
128
|
+
schema, err := jsonschema.For[retrieval.MapInput](nil)
|
|
129
|
+
if err != nil {
|
|
130
|
+
panic(err)
|
|
131
|
+
}
|
|
132
|
+
return schema
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Manual validation keeps data errors in successful MCP tool responses rather
|
|
136
|
+
// than surfacing them as client-side INVALID_ARGUMENT tool exceptions.
|
|
137
|
+
func dataHandler[In any](fn func(In) (map[string]any, error)) mcp.ToolHandler {
|
|
138
|
+
return func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
139
|
+
var input In
|
|
140
|
+
args := req.Params.Arguments
|
|
141
|
+
if len(args) == 0 {
|
|
142
|
+
args = []byte("{}")
|
|
143
|
+
}
|
|
144
|
+
decoder := json.NewDecoder(bytes.NewReader(args))
|
|
145
|
+
decoder.DisallowUnknownFields()
|
|
146
|
+
if err := decoder.Decode(&input); err != nil {
|
|
147
|
+
response, _, e := dataMCPResult(nil, &retrieval.DataError{Code: "invalid_arguments", Message: err.Error()})
|
|
148
|
+
return response, e
|
|
149
|
+
}
|
|
150
|
+
result, err := fn(input)
|
|
151
|
+
response, _, e := dataMCPResult(result, err)
|
|
152
|
+
return response, e
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
func dataMCPResult(result map[string]any, err error) (*mcp.CallToolResult, any, error) {
|
|
84
157
|
failed := err != nil
|
|
85
158
|
if failed {
|
|
86
159
|
// Use the shared error serializer to preserve codes, corrective details
|
|
87
160
|
// and provider-term acceptance URLs as structured tool output.
|
|
88
161
|
result = retrieval.DataErrorResult(err)
|
|
162
|
+
result["ok"] = false
|
|
163
|
+
} else {
|
|
164
|
+
result["ok"] = true
|
|
89
165
|
}
|
|
90
166
|
bytes, marshalErr := json.MarshalIndent(result, "", " ")
|
|
91
167
|
if marshalErr != nil {
|
|
@@ -94,7 +170,9 @@ func dataMCPResult(result map[string]any, err error) (*mcp.CallToolResult, any,
|
|
|
94
170
|
return &mcp.CallToolResult{
|
|
95
171
|
Content: []mcp.Content{&mcp.TextContent{Text: string(bytes)}},
|
|
96
172
|
StructuredContent: result,
|
|
97
|
-
|
|
173
|
+
// Keep validation/provider failures in the structured result. Clients
|
|
174
|
+
// commonly convert isError=true to opaque INVALID_ARGUMENT exceptions.
|
|
175
|
+
IsError: false,
|
|
98
176
|
}, nil, nil
|
|
99
177
|
}
|
|
100
178
|
|
package/pkg/origo/server_test.go
CHANGED
|
@@ -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":{}}`)
|
|
@@ -62,11 +114,11 @@ func TestFiveToolsWithProgressiveDataInterface(t *testing.T) {
|
|
|
62
114
|
if count := strings.Count(text, `"name":`); count != 5 {
|
|
63
115
|
t.Fatalf("expected exactly 5 tools, got %d: %s", count, text)
|
|
64
116
|
}
|
|
65
|
-
if count := strings.Count(text, `"readOnlyHint":true`); count !=
|
|
66
|
-
t.Fatalf("expected
|
|
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)
|
|
67
119
|
}
|
|
68
|
-
if
|
|
69
|
-
t.Fatalf("
|
|
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)
|
|
70
122
|
}
|
|
71
123
|
}
|
|
72
124
|
|
package/pkg/retrieval/data.go
CHANGED
|
@@ -6,8 +6,11 @@ type ResearchInput = app.ResearchDataInput
|
|
|
6
6
|
type InspectInput = app.InspectDataInput
|
|
7
7
|
type ExecuteInput = app.ExecuteDataInput
|
|
8
8
|
type DataCall = app.DataCall
|
|
9
|
+
type DataError = app.DataError
|
|
10
|
+
type MapInput = app.MapSiteInput
|
|
9
11
|
|
|
10
12
|
func Research(input ResearchInput) (map[string]any, error) { return app.ResearchData(input) }
|
|
11
13
|
func Inspect(input InspectInput) (map[string]any, error) { return app.InspectData(input) }
|
|
12
14
|
func Execute(input ExecuteInput) (map[string]any, error) { return app.ExecuteData(input) }
|
|
13
15
|
func DataErrorResult(err error) map[string]any { return app.DataErrorResult(err) }
|
|
16
|
+
func MapSitePage(input MapInput) (map[string]any, error) { return app.MapSitePage(input) }
|