webctx 0.2.1 → 0.2.3

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,249 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "path"
11
+ "regexp"
12
+ "sort"
13
+ "strings"
14
+ "time"
15
+ )
16
+
17
+ const maxDirectSourceBytes = 1024 * 1024
18
+
19
+ var (
20
+ jsonLDPattern = regexp.MustCompile(`(?is)<script\b[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>(.*?)</script\s*>`)
21
+ nextDataPattern = regexp.MustCompile(`(?is)<script\b[^>]*id\s*=\s*["']__NEXT_DATA__["'][^>]*>(.*?)</script\s*>`)
22
+ )
23
+
24
+ // These URLs are already structured source documents. Never scrape a raw
25
+ // OpenAPI schema through HTML rendering when we can retrieve its actual bytes.
26
+ func directSourceLanguage(rawURL string) string {
27
+ u, err := url.Parse(rawURL)
28
+ if err != nil {
29
+ return ""
30
+ }
31
+ switch strings.ToLower(path.Ext(u.Path)) {
32
+ case ".json", ".jsonl":
33
+ return "json"
34
+ case ".yaml", ".yml":
35
+ return "yaml"
36
+ case ".toml":
37
+ return "toml"
38
+ case ".xml":
39
+ return "xml"
40
+ case ".graphql", ".gql":
41
+ return "graphql"
42
+ case ".txt":
43
+ return "text"
44
+ default:
45
+ return ""
46
+ }
47
+ }
48
+
49
+ // A bounded, redirect-checked direct read prevents accidental traversal from
50
+ // an accepted public source to an internal address.
51
+ func fetchPublicText(ctx context.Context, rawURL string, maxBytes int64) (string, error) {
52
+ if err := validateSourceURL(rawURL); err != nil {
53
+ return "", err
54
+ }
55
+ client := *http.DefaultClient
56
+ oldCheck := client.CheckRedirect
57
+ client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
58
+ if err := validateSourceURL(req.URL.String()); err != nil {
59
+ return err
60
+ }
61
+ if oldCheck != nil {
62
+ return oldCheck(req, via)
63
+ }
64
+ if len(via) >= 10 {
65
+ return fmt.Errorf("too many redirects")
66
+ }
67
+ return nil
68
+ }
69
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
70
+ if err != nil {
71
+ return "", err
72
+ }
73
+ req.Header.Set("Accept", "application/json, text/markdown, text/plain, text/html, application/yaml, */*;q=0.5")
74
+ resp, err := client.Do(req)
75
+ if err != nil {
76
+ return "", err
77
+ }
78
+ defer resp.Body.Close()
79
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
80
+ return "", fmt.Errorf("source HTTP %d", resp.StatusCode)
81
+ }
82
+ if resp.ContentLength > maxBytes {
83
+ return "", fmt.Errorf("source is larger than the supported %d bytes", maxBytes)
84
+ }
85
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
86
+ if err != nil {
87
+ return "", err
88
+ }
89
+ if int64(len(body)) > maxBytes {
90
+ return "", fmt.Errorf("source exceeded the supported %d bytes", maxBytes)
91
+ }
92
+ return string(body), nil
93
+ }
94
+
95
+ func readDirectSource(rawURL string) (string, bool) {
96
+ language := directSourceLanguage(rawURL)
97
+ if language == "" {
98
+ return "", false
99
+ }
100
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
101
+ defer cancel()
102
+ body, err := fetchPublicText(ctx, rawURL, maxDirectSourceBytes)
103
+ if err != nil || strings.TrimSpace(body) == "" {
104
+ return "", false
105
+ }
106
+ if strings.Contains(strings.ToLower(body[:min(len(body), 256)]), "<html") {
107
+ return "", false
108
+ }
109
+ if language == "json" && !json.Valid([]byte(body)) { // don't label HTML/garbage as JSON
110
+ return "", false
111
+ }
112
+ return fmt.Sprintf("**URL:** %s\n\n```%s\n%s\n```", rawURL, language, strings.TrimSpace(body)), true
113
+ }
114
+
115
+ // Extract only public machine-readable metadata actually embedded in the
116
+ // original HTML. Never execute arbitrary scripts or speculate about hidden APIs.
117
+ func embeddedStructuredContext(rawURL, question string) string {
118
+ if question == "" || directSourceLanguage(rawURL) != "" {
119
+ return ""
120
+ }
121
+ if u, err := url.Parse(rawURL); err == nil && strings.EqualFold(u.Hostname(), "github.com") {
122
+ return "" // GitHub's native APIs are more faithful than page hydration data.
123
+ }
124
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
125
+ defer cancel()
126
+ html, err := fetchPublicText(ctx, rawURL, maxDirectSourceBytes)
127
+ if err != nil || !strings.Contains(strings.ToLower(html), "<script") {
128
+ return ""
129
+ }
130
+ var results []string
131
+ for _, pattern := range []*regexp.Regexp{jsonLDPattern, nextDataPattern} {
132
+ for _, match := range pattern.FindAllStringSubmatch(html, 3) {
133
+ var raw any
134
+ if json.Unmarshal([]byte(strings.TrimSpace(match[1])), &raw) != nil {
135
+ continue
136
+ }
137
+ selection := selectedJSONFields(raw, question, 2500)
138
+ if selection != "" {
139
+ results = append(results, selection)
140
+ }
141
+ }
142
+ }
143
+ if len(results) == 0 {
144
+ return ""
145
+ }
146
+ return "### Embedded source metadata\n\nStructured JSON embedded in the source page (not interpreted as an API response):\n\n" + strings.Join(results, "\n\n")
147
+ }
148
+
149
+ // selectedJSONFields preserves exact JSON values and JSON Pointers rather
150
+ // than returning syntactically broken chunks of a large schema or payload.
151
+ func selectedJSONFields(raw any, question string, budget int) string {
152
+ type field struct {
153
+ path string
154
+ value any
155
+ score int
156
+ }
157
+ terms := queryTerms(question)
158
+ fields := make([]field, 0, 32)
159
+ var walk func(any, string, int)
160
+ walk = func(value any, parent string, depth int) {
161
+ if depth > 9 || len(fields) >= 500 {
162
+ return
163
+ }
164
+ switch v := value.(type) {
165
+ case map[string]any:
166
+ for key, child := range v {
167
+ pointer := parent + "/" + strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1")
168
+ score := termScore(key, terms)*4 + termScore(pointer, terms)
169
+ if score > 0 {
170
+ fields = append(fields, field{path: pointer, value: child, score: score})
171
+ }
172
+ walk(child, pointer, depth+1)
173
+ }
174
+ case []any:
175
+ for i, child := range v {
176
+ if i >= 20 { // never flatten unbounded payload collections
177
+ break
178
+ }
179
+ walk(child, fmt.Sprintf("%s/%d", parent, i), depth+1)
180
+ }
181
+ }
182
+ }
183
+ walk(raw, "", 0)
184
+ sort.SliceStable(fields, func(i, j int) bool {
185
+ if fields[i].score == fields[j].score {
186
+ return fields[i].path < fields[j].path
187
+ }
188
+ return fields[i].score > fields[j].score
189
+ })
190
+ var out []string
191
+ for _, f := range fields {
192
+ value, err := json.MarshalIndent(f.value, "", " ")
193
+ if err != nil || len(value) > budget/2 || len(value) < 2 {
194
+ continue
195
+ }
196
+ item := fmt.Sprintf("**JSON Pointer:** `%s`\n\n```json\n%s\n```", f.path, value)
197
+ if len(strings.Join(out, "\n\n"))+len(item) > budget {
198
+ break
199
+ }
200
+ out = append(out, item)
201
+ if len(out) >= 5 {
202
+ break
203
+ }
204
+ }
205
+ if len(out) > 0 {
206
+ return strings.Join(out, "\n\n") + "\n\n*[Selected JSON fields; full document at source URL.]*"
207
+ }
208
+ pretty, err := json.MarshalIndent(raw, "", " ")
209
+ if err != nil {
210
+ return ""
211
+ }
212
+ if len(pretty)+20 <= budget {
213
+ return "```json\n" + string(pretty) + "\n```"
214
+ }
215
+ // No relevant key matched: expose the shape without claiming completeness.
216
+ if obj, ok := raw.(map[string]any); ok {
217
+ keys := make([]string, 0, len(obj))
218
+ for key := range obj {
219
+ keys = append(keys, key)
220
+ }
221
+ sort.Strings(keys)
222
+ if len(keys) > 25 {
223
+ keys = keys[:25]
224
+ }
225
+ return "**Top-level JSON keys:** " + strings.Join(keys, ", ") + " (full document at source URL)"
226
+ }
227
+ return "*[Structured document is larger than the inline budget; inspect original source.]*"
228
+ }
229
+
230
+ func focusedRawJSON(markdown, question string, budget int) string {
231
+ begin := strings.Index(markdown, "```json\n")
232
+ if begin < 0 {
233
+ return ""
234
+ }
235
+ jsonStart := begin + len("```json\n")
236
+ end := strings.LastIndex(markdown, "\n```")
237
+ if end <= jsonStart {
238
+ return ""
239
+ }
240
+ var source any
241
+ if json.Unmarshal([]byte(markdown[jsonStart:end]), &source) != nil {
242
+ return ""
243
+ }
244
+ selected := selectedJSONFields(source, question, budget-250)
245
+ if selected == "" {
246
+ return ""
247
+ }
248
+ return strings.TrimSpace(markdown[:begin]) + "\n\n**Focused JSON evidence:**\n\n" + selected
249
+ }
@@ -0,0 +1,70 @@
1
+ package app
2
+
3
+ import (
4
+ "io"
5
+ "net/http"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestDirectOpenAPIJSONBypassesScraping(t *testing.T) {
11
+ old := http.DefaultClient
12
+ t.Cleanup(func() { http.DefaultClient = old })
13
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
14
+ if req.URL.Host != "docs.example.com" || req.URL.Path != "/openapi.json" || req.Method != http.MethodGet {
15
+ t.Fatalf("unexpected direct source request: %s", req.URL)
16
+ }
17
+ return testHTTPResponse(req, http.StatusOK, `{"openapi":"3.1.0","paths":{"/v2/scrape":{}}}`, nil), nil
18
+ })}
19
+ got, err := ReadLink("https://docs.example.com/openapi.json")
20
+ if err != nil || !strings.Contains(got, `"openapi":"3.1.0"`) || !strings.Contains(got, "```json") {
21
+ t.Fatalf("did not fetch raw OpenAPI: %q, %v", got, err)
22
+ }
23
+ }
24
+
25
+ func TestDirectSourceRejectsPrivateRedirects(t *testing.T) {
26
+ old := http.DefaultClient
27
+ t.Cleanup(func() { http.DefaultClient = old })
28
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
29
+ if req.URL.Host != "docs.example.com" {
30
+ t.Fatalf("followed unsafe redirect to %s", req.URL)
31
+ }
32
+ return testHTTPResponse(req, http.StatusFound, "", map[string]string{"Location": "http://127.0.0.1/private"}), nil
33
+ })}
34
+ if _, err := fetchPublicText(t.Context(), "https://docs.example.com/openapi.json", 100); err == nil {
35
+ t.Fatal("should reject public-to-private redirects")
36
+ }
37
+ }
38
+
39
+ func TestAlexandriaDiscoveryFallsBackWhenNotEnabled(t *testing.T) {
40
+ old := http.DefaultClient
41
+ t.Cleanup(func() { http.DefaultClient = old })
42
+ var calls int
43
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
44
+ body, _ := io.ReadAll(req.Body)
45
+ calls++
46
+ if calls == 1 {
47
+ if !strings.Contains(string(body), `"domainTools":true`) {
48
+ t.Fatalf("Alexandria discovery not enabled: %s", body)
49
+ }
50
+ return testHTTPResponse(req, http.StatusForbidden, `{"error":"Alexandria unavailable"}`, nil), nil
51
+ }
52
+ if !strings.Contains(string(body), `"domainTools":false`) {
53
+ t.Fatalf("fallback did not disable domain matching: %s", body)
54
+ }
55
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"markdown":"# Good source\n\nAuthoritative page content."}}`, nil), nil
56
+ })}
57
+ result, err := firecrawlScrape("https://docs.example.com/reference", "fake-test-key", "")
58
+ if err != nil || calls != 2 || !strings.Contains(result.Markdown, "Good source") {
59
+ t.Fatalf("fallback broke regular scraping: %#v, %v, calls=%d", result, err, calls)
60
+ }
61
+ }
62
+
63
+ func TestAlexandriaToolDescriptionIsDiscoveryOnly(t *testing.T) {
64
+ got := formatAlexandriaTools([]any{
65
+ map[string]any{"provider": "particle", "capability": "podcasts/episodes/search", "description": "Find episodes"},
66
+ })
67
+ if !strings.Contains(got, "particle/podcasts/episodes/search") || !strings.Contains(got, "not executed") {
68
+ t.Fatalf("bad tool discovery metadata: %s", got)
69
+ }
70
+ }
@@ -51,6 +51,11 @@ func Search(params SearchParams) (string, error) {
51
51
  allExcludedDomains := append(append([]string{}, defaultExcludedDomains...), params.ExcludeDomains...)
52
52
  truncatedKeyword := truncateWords(params.IncludeKeyword, 5)
53
53
 
54
+ // Free Alexandria tool discovery proceeds alongside normal web search.
55
+ // It never executes a paid tool or replaces source-grounded web results.
56
+ alexandriaDone := make(chan string, 1)
57
+ go func() { alexandriaDone <- discoverAlexandria(params.Query) }()
58
+
54
59
  type namedSearch struct {
55
60
  name string
56
61
  fn func(context.Context) (SearchResult, error)
@@ -118,6 +123,15 @@ func Search(params SearchParams) (string, error) {
118
123
  }
119
124
 
120
125
  parts := []string{fmt.Sprintf("Total Results: %d\n", len(ranked))}
126
+ // The ranked index remains available, but agents also get useful source
127
+ // context without having to issue several follow-up read-link calls.
128
+ if context := enrichSearch(params.Query, ranked); context != "" {
129
+ parts = append(parts, context, "")
130
+ }
131
+ if tools := <-alexandriaDone; tools != "" {
132
+ parts = append(parts, "", "## Related data providers", tools, "")
133
+ }
134
+ parts = append(parts, "## Search results", "")
121
135
  for _, doc := range ranked {
122
136
  title := decodeHTML(doc.Title)
123
137
  overview := decodeHTML(doc.Overview)
@@ -131,6 +145,9 @@ func Search(params SearchParams) (string, error) {
131
145
  }
132
146
 
133
147
  func ReadLink(rawURL string) (string, error) {
148
+ if err := validateSourceURL(rawURL); err != nil {
149
+ return "", err
150
+ }
134
151
  native := readGitHubNative(rawURL)
135
152
  switch native.Outcome {
136
153
  case GitHubNativeSuccess:
@@ -150,6 +167,10 @@ func ReadLink(rawURL string) (string, error) {
150
167
  return "", native.Err
151
168
  }
152
169
 
170
+ if direct, ok := readDirectSource(rawURL); ok {
171
+ return direct, nil
172
+ }
173
+
153
174
  if ok, _ := checkMarkdownAvailable(rawURL); ok {
154
175
  result, err := fetchMarkdownContent(rawURL)
155
176
  if err == nil {
@@ -193,55 +214,38 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
193
214
  if apiKey == "" {
194
215
  return "", "", missingCredentialsError("Error reading web page", []string{"FIRECRAWL_API_KEY"}, "for non-.md URLs")
195
216
  }
196
-
197
- requestBody := map[string]any{
198
- "url": rawURL,
199
- "formats": []string{"markdown"},
200
- "onlyMainContent": true,
201
- "skipTlsVerification": true,
202
- "blockAds": true,
203
- "removeBase64Images": true,
204
- "maxAge": 600000,
205
- "excludeTags": []string{"script", "style", "meta", "noscript", "svg", "img", "nav", "footer", "header", "aside", ".advertisement", "#ad"},
206
- }
207
- if strings.HasSuffix(strings.ToLower(rawURL), ".pdf") {
208
- requestBody["parsers"] = []string{"pdf"}
209
- }
210
-
211
- data, err := getFirecrawlQueue().enqueue(rawURL, func() (map[string]any, error) {
212
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
213
- defer cancel()
214
- body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/scrape", map[string]string{
215
- "Authorization": "Bearer " + apiKey,
216
- "Content-Type": "application/json",
217
- }, requestBody)
218
- if err != nil {
219
- return nil, err
220
- }
221
- var parsed map[string]any
222
- if err := json.Unmarshal(body, &parsed); err != nil {
223
- return nil, err
217
+ // Firecrawl's default auto proxy already tries basic then enhanced when the
218
+ // transport fails. A second explicit enhanced attempt is only useful when a
219
+ // scrape responds successfully but returns a challenge/empty document.
220
+ first, err := firecrawlScrape(rawURL, apiKey, "")
221
+ if err == nil && usableScrapedMarkdown(first.Markdown) {
222
+ return first.Title, withSourceTools(first.Markdown, first.ToolSummary), nil
223
+ }
224
+ lastError := err
225
+ if err == nil {
226
+ lastError = fmt.Errorf("Firecrawl returned empty or blocked page content")
227
+ second, enhancedErr := firecrawlScrape(rawURL, apiKey, "enhanced")
228
+ if enhancedErr == nil && usableScrapedMarkdown(second.Markdown) {
229
+ return second.Title, withSourceTools(second.Markdown, second.ToolSummary), nil
224
230
  }
225
- if success, _ := parsed["success"].(bool); !success {
226
- return nil, fmt.Errorf("Scraping failed for %s: %v", rawURL, parsed["error"])
231
+ if enhancedErr != nil {
232
+ lastError = enhancedErr
227
233
  }
228
- return parsed, nil
229
- })
230
- if err != nil {
231
- return "", "", fmt.Errorf("Error reading web page: %v", err)
232
234
  }
233
-
234
- dataMap, _ := data["data"].(map[string]any)
235
- metadata, _ := dataMap["metadata"].(map[string]any)
236
- title, _ := metadata["title"].(string)
237
- markdown, _ := dataMap["markdown"].(string)
238
- if markdown == "" {
239
- markdown = "No content extracted"
235
+ title, markdown, browserErr := firecrawlBrowserFallback(rawURL, apiKey)
236
+ if browserErr == nil && usableScrapedMarkdown(markdown) {
237
+ return title, markdown, nil
238
+ }
239
+ if browserErr == nil {
240
+ browserErr = fmt.Errorf("browser produced no usable content")
240
241
  }
241
- return title, markdown, nil
242
+ return "", "", fmt.Errorf("Error reading web page: scrape: %v; browser fallback: %w", lastError, browserErr)
242
243
  }
243
244
 
244
245
  func MapSite(rawURL string) (string, error) {
246
+ if err := validateSourceURL(rawURL); err != nil {
247
+ return "", err
248
+ }
245
249
  apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
246
250
  if apiKey == "" {
247
251
  return "", missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pure Go web search and browsing CLI using Brave, Tavily, Exa, and Firecrawl",
5
5
  "license": "MIT",
6
6
  "author": "amxv",
@@ -24,6 +24,8 @@
24
24
  "cmd",
25
25
  "internal",
26
26
  "go.mod",
27
+ "go.sum",
28
+ "pkg",
27
29
  "README.md",
28
30
  "docs",
29
31
  "LICENSE",
@@ -0,0 +1,86 @@
1
+ // Package origo provides the independently deployed MCP transport for the
2
+ // shared WebCTX retrieval engine. Search is intentionally not exposed.
3
+ package origo
4
+
5
+ import (
6
+ "context"
7
+ "crypto/sha256"
8
+ "crypto/subtle"
9
+ "net/http"
10
+ "os"
11
+ "strings"
12
+
13
+ "github.com/amxv/webctx/pkg/retrieval"
14
+ "github.com/modelcontextprotocol/go-sdk/mcp"
15
+ )
16
+
17
+ type linkInput struct {
18
+ URL string `json:"url" jsonschema:"Absolute HTTP(S) URL to read as source-grounded Markdown"`
19
+ }
20
+
21
+ type readLinkInput struct {
22
+ URL string `json:"url" jsonschema:"Absolute HTTP(S) URL to read as source-grounded Markdown"`
23
+ Question string `json:"question,omitempty" jsonschema:"Optional question or task. When set, automatically follows relevant source links and returns focused, cited context."`
24
+ }
25
+
26
+ func makeServer() *mcp.Server {
27
+ s := mcp.NewServer(&mcp.Implementation{Name: "origo", Version: "0.1.0"}, nil)
28
+ mcp.AddTool(s, &mcp.Tool{
29
+ Name: "read_link",
30
+ Title: "Read link",
31
+ Description: "Read a URL as source-grounded Markdown. Optionally provide a question to automatically explore related docs and return relevant API contracts, code examples and precise source URLs. No search query required; no paid Alexandria tool execution.",
32
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
33
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input readLinkInput) (*mcp.CallToolResult, any, error) {
34
+ result, err := retrieval.ReadLinkFocused(input.URL, input.Question)
35
+ if err != nil {
36
+ return nil, nil, err
37
+ }
38
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
39
+ })
40
+ mcp.AddTool(s, &mcp.Tool{
41
+ Name: "map_site",
42
+ Title: "Map site",
43
+ Description: "Discover URLs on a site using Firecrawl's sitemap-based map API. Does not perform web search.",
44
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
45
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
46
+ result, err := retrieval.MapSite(input.URL)
47
+ if err != nil {
48
+ return nil, nil, err
49
+ }
50
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
51
+ })
52
+ return s
53
+ }
54
+
55
+ var transport = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
56
+ return makeServer()
57
+ }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
58
+
59
+ func Handler() http.Handler {
60
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61
+ w.Header().Set("Cache-Control", "no-store")
62
+ w.Header().Set("Referrer-Policy", "no-referrer")
63
+ w.Header().Set("X-Content-Type-Options", "nosniff")
64
+ w.Header().Set("X-Robots-Tag", "noindex")
65
+ secret := strings.TrimSpace(os.Getenv("ORIGO_API_KEY"))
66
+ if secret == "" {
67
+ http.Error(w, "MCP endpoint not configured", http.StatusServiceUnavailable)
68
+ return
69
+ }
70
+ keys := r.URL.Query()["key"]
71
+ if len(keys) != 1 || !validKey(secret, keys[0]) {
72
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
73
+ return
74
+ }
75
+ transport.ServeHTTP(w, r)
76
+ })
77
+ }
78
+
79
+ func validKey(expected, supplied string) bool {
80
+ if supplied == "" {
81
+ return false
82
+ }
83
+ want := sha256.Sum256([]byte(expected))
84
+ got := sha256.Sum256([]byte(supplied))
85
+ return subtle.ConstantTimeCompare(want[:], got[:]) == 1
86
+ }
@@ -0,0 +1,78 @@
1
+ package origo
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func request(t *testing.T, key, body string) *httptest.ResponseRecorder {
11
+ t.Helper()
12
+ path := "/mcp"
13
+ if key != "" {
14
+ path += "?key=" + key
15
+ }
16
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
17
+ req.Header.Set("Content-Type", "application/json")
18
+ req.Header.Set("Accept", "application/json, text/event-stream")
19
+ req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
20
+ response := httptest.NewRecorder()
21
+ Handler().ServeHTTP(response, req)
22
+ return response
23
+ }
24
+
25
+ func TestServerRequiresConfiguredKey(t *testing.T) {
26
+ t.Setenv("ORIGO_API_KEY", "")
27
+ response := request(t, "somekey", `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`)
28
+ if response.Code != http.StatusServiceUnavailable {
29
+ t.Fatalf("without a configured key: expected 503, got %d", response.Code)
30
+ }
31
+ }
32
+
33
+ func TestServerAuthentication(t *testing.T) {
34
+ t.Setenv("ORIGO_API_KEY", "secret")
35
+ for _, key := range []string{"", "wrong"} {
36
+ response := request(t, key, `{}`)
37
+ if response.Code != http.StatusUnauthorized {
38
+ t.Fatalf("key %q: expected 401, got %d", key, response.Code)
39
+ }
40
+ if response.Header().Get("Cache-Control") != "no-store" {
41
+ t.Fatalf("no-store header missing")
42
+ }
43
+ }
44
+ response := request(t, "secret", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`)
45
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"name":"origo"`) {
46
+ t.Fatalf("initialize failed: %d %s", response.Code, response.Body.String())
47
+ }
48
+ }
49
+
50
+ func TestExactlyTwoToolsAndNoSearch(t *testing.T) {
51
+ t.Setenv("ORIGO_API_KEY", "secret")
52
+ response := request(t, "secret", `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
53
+ if response.Code != http.StatusOK {
54
+ t.Fatalf("tools/list failed: %d %s", response.Code, response.Body.String())
55
+ }
56
+ text := response.Body.String()
57
+ if !strings.Contains(text, `"name":"read_link"`) || !strings.Contains(text, `"name":"map_site"`) || strings.Contains(text, `"name":"search"`) {
58
+ t.Fatalf("unexpected tools: %s", text)
59
+ }
60
+ if count := strings.Count(text, `"name":`); count != 2 {
61
+ t.Fatalf("expected exactly 2 tools, got %d: %s", count, text)
62
+ }
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)
65
+ }
66
+ }
67
+
68
+ func TestQuestionIsOptionalOnReadLinkOnly(t *testing.T) {
69
+ t.Setenv("ORIGO_API_KEY", "secret")
70
+ response := request(t, "secret", `{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}`)
71
+ if response.Code != http.StatusOK {
72
+ t.Fatalf("list tools: %s", response.Body.String())
73
+ }
74
+ text := response.Body.String()
75
+ if !strings.Contains(text, `"question"`) || !strings.Contains(text, `"required":["url"]`) {
76
+ t.Fatalf("question should be optional; URL remains required: %s", text)
77
+ }
78
+ }
@@ -0,0 +1,17 @@
1
+ // Package retrieval exposes WebCTX's shared retrieval engine to non-CLI
2
+ // transports. The CLI remains backward-compatible while Origo MCP can call
3
+ // these functions without importing Go internal packages from Vercel's
4
+ // generated handler package.
5
+ package retrieval
6
+
7
+ import "github.com/amxv/webctx/internal/app"
8
+
9
+ func ReadLink(url string) (string, error) { return app.ReadLink(url) }
10
+
11
+ // ReadLinkFocused follows related documentation when the caller has a question.
12
+ // A URL-only call retains the original complete-document behavior.
13
+ func ReadLinkFocused(url, question string) (string, error) {
14
+ return app.ReadLinkFocused(url, question)
15
+ }
16
+
17
+ func MapSite(url string) (string, error) { return app.MapSite(url) }