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.
@@ -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
- return nil, nil, err
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
- mcp.AddTool(s, &mcp.Tool{
47
+ s.AddTool(&mcp.Tool{
42
48
  Name: "map_site",
43
49
  Title: "Map site",
44
- Description: "Discover URLs on a site using Firecrawl's sitemap-based map API. Does not perform web search.",
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
- }, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
47
- result, err := retrieval.MapSite(input.URL)
48
- if err != nil {
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
- }, 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{
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
- }, 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{
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 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
- })
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
- IsError: failed,
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
 
@@ -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 != 4 {
66
- t.Fatalf("expected the four free retrieval tools to be read-only, got %d: %s", count, text)
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 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)
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
 
@@ -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) }