webctx 0.2.1 → 0.2.2

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/AGENTS.md CHANGED
@@ -4,7 +4,7 @@ Guidance for coding agents working in `webctx`.
4
4
 
5
5
  ## Purpose
6
6
 
7
- This repo contains the pure Go port of the `webctx` CLI.
7
+ This repo contains the Go `webctx` CLI and its hosted Origo MCP transport, sharing one retrieval engine.
8
8
 
9
9
  It is no longer a generic starter template. Treat the current CLI behavior as the source of truth unless the user explicitly asks to change it.
10
10
 
@@ -14,6 +14,9 @@ It is no longer a generic starter template. Treat the current CLI behavior as th
14
14
  - `internal/app/app.go`: CLI parsing and top-level command routing.
15
15
  - `internal/app/tools.go`: search provider clients, ranking, formatting, and HTTP helpers.
16
16
  - `internal/app/scrape.go`: GitHub raw-content optimization, `.md` fetch path, Firecrawl queue, and env loading.
17
+ - `pkg/retrieval/`: public adapter to `internal/app` for Vercel's Go packaging restrictions.
18
+ - `pkg/origo/`: authenticated Streamable HTTP MCP with exactly `read_link` and `map_site`.
19
+ - `api/mcp.go`: Go Function entrypoint for independent Origo deployment.
17
20
  - `internal/app/app_test.go`: unit tests for CLI behavior and core helpers.
18
21
  - `bin/webctx.js`: npm shim that invokes the packaged native binary.
19
22
  - `scripts/postinstall.js`: downloads release binary on install, falls back to `go build`.
@@ -51,7 +54,7 @@ Preserve these commands unless the user explicitly asks to change them:
51
54
  Behavioral expectations:
52
55
 
53
56
  - `search` combines Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring.
54
- - `read-link` keeps the current GitHub raw-content fast path, `.md` fast path, and Firecrawl fallback settings.
57
+ - `read-link` keeps the current GitHub raw-content and `.md` fast paths. Firecrawl uses its built-in 30-minute cache, automatic and enhanced proxies, then browser extraction as a final fallback.
55
58
  - `map-site` keeps the current Firecrawl map request settings.
56
59
  - The CLI should remain agent-friendly and emit plain markdown/text output.
57
60
 
@@ -81,7 +84,9 @@ Release binaries should embed the tagged version into `internal/buildinfo.Versio
81
84
 
82
85
  - Prefer additive changes and keep the CLI output stable.
83
86
  - Do not silently change Firecrawl request settings unless the user explicitly wants behavioral changes.
84
- - Do not reintroduce MCP/server code unless requested; this repo is intentionally CLI-only.
87
+ - Do not add search to Origo's MCP tool surface; only `read_link` and `map_site` are registered.
88
+ - Keep `origo-api` deployment separate from the existing WebCTX docs Vercel project and tag-driven CLI releases.
89
+ - Never introduce Redis, custom KV, or another caching service for Origo: the user explicitly chose Firecrawl's own 30-minute cache.
85
90
 
86
91
  ## Changelog Guidelines
87
92
 
package/CONTRIBUTORS.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Maintainer notes for `webctx`.
4
4
 
5
+ Origo is the separately deployed MCP transport in this same repository. The CLI, MCP, and ZueDocs share source code but have independent distribution pipelines.
6
+
5
7
  ## Prerequisites
6
8
 
7
9
  - Go `1.26+`
@@ -105,12 +107,22 @@ npm whoami
105
107
 
106
108
  - `cmd/webctx/main.go`: CLI entrypoint
107
109
  - `internal/app/`: CLI parsing, search, ranking, scrape, env loading, and Firecrawl queue logic
110
+ - `pkg/retrieval/`: small public adapter to the shared read-link / site-map engine
111
+ - `pkg/origo/`: authenticated, stateless MCP server with exactly two tools
112
+ - `api/mcp.go`: Vercel Go Function entrypoint for Origo
113
+ - `vercel.mjs`: project-specific Vercel settings for docs and Origo
108
114
  - `internal/buildinfo/`: build-time version plumbing for `--version`
109
115
  - `bin/webctx.js`: npm shim that invokes the packaged native binary
110
116
  - `scripts/postinstall.js`: downloads the release binary on install and falls back to local `go build`
111
117
  - `.github/workflows/release.yml`: tag-driven release pipeline
112
118
  - `AGENTS.md`: guidance for coding agents
113
119
 
120
+ ## Origo deployment
121
+
122
+ The `origo-api` Vercel project is linked to this GitHub repo and uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
123
+
124
+ Both projects deploy automatically from `main`. `scripts/should-build.mjs` gates docs and API builds independently, while `v*` GitHub tags still drive the CLI release workflow.
125
+
114
126
  ## Notes on package naming
115
127
 
116
128
  `webctx` is already configured. If you ever rename or move the package, update all of the following together:
package/README.md CHANGED
@@ -12,6 +12,14 @@ webctx map-site <url>
12
12
 
13
13
  The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool.
14
14
 
15
+ ## Origo: hosted MCP access
16
+
17
+ **Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access exactly two tools: `read_link` and `map_site`. Origo does not expose `search`; the CLI continues to provide all three commands.
18
+
19
+ Both surfaces share native GitHub/Markdown readers and Firecrawl fallback behavior. Firecrawl's built-in cache is limited to **30 minutes**. Difficult pages automatically escalate through Firecrawl's proxy fallback and, if necessary, a temporary Browser Sandbox session. There is no Redis or additional cache database.
20
+
21
+ Origo deploys independently through the `origo-api` Vercel project on pushes to `main`. CLI releases and npm publishing are unchanged. See the [Origo MCP guide](https://webctx.ashray.xyz/docs/origo) for architecture, setup, and security notes.
22
+
15
23
  ## Install
16
24
 
17
25
  ```bash
package/go.mod CHANGED
@@ -1,3 +1,16 @@
1
1
  module github.com/amxv/webctx
2
2
 
3
3
  go 1.26
4
+
5
+ require github.com/modelcontextprotocol/go-sdk v1.8.0
6
+
7
+ require (
8
+ github.com/google/jsonschema-go v0.4.3 // indirect
9
+ github.com/segmentio/asm v1.1.3 // indirect
10
+ github.com/segmentio/encoding v0.5.4 // indirect
11
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
12
+ golang.org/x/oauth2 v0.35.0 // indirect
13
+ golang.org/x/sync v0.20.0 // indirect
14
+ golang.org/x/sys v0.41.0 // indirect
15
+ golang.org/x/time v0.15.0 // indirect
16
+ )
package/go.sum ADDED
@@ -0,0 +1,24 @@
1
+ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
2
+ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5
+ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
6
+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
7
+ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
8
+ github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
9
+ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
10
+ github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
11
+ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
12
+ github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
13
+ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
14
+ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
15
+ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
16
+ golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
17
+ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
18
+ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
19
+ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
20
+ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
21
+ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
22
+ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
23
+ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
24
+ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
@@ -0,0 +1,163 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+ )
13
+
14
+ type scrapedPage struct {
15
+ Title string
16
+ Markdown string
17
+ }
18
+
19
+ func usableScrapedMarkdown(markdown string) bool {
20
+ text := strings.ToLower(strings.TrimSpace(markdown))
21
+ if len(text) < 10 {
22
+ return false
23
+ }
24
+ for _, marker := range []string{
25
+ "just a moment...", "checking your browser", "verify you are human",
26
+ "enable javascript and cookies", "access denied | cloudflare",
27
+ "attention required! | cloudflare", "no content extracted",
28
+ } {
29
+ if strings.Contains(text, marker) && len(text) < 2000 {
30
+ return false
31
+ }
32
+ }
33
+ return true
34
+ }
35
+
36
+ func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
37
+ requestBody := map[string]any{
38
+ "url": rawURL,
39
+ "formats": []string{"markdown"},
40
+ "onlyMainContent": true,
41
+ "blockAds": true,
42
+ "removeBase64Images": true,
43
+ "maxAge": 30 * 60 * 1000, // Firecrawl's built-in cache, 30 minutes.
44
+ "excludeTags": []string{"script", "style", "meta", "noscript", "svg", "img", "nav", "footer", "header", "aside", ".advertisement", "#ad"},
45
+ }
46
+ if proxy != "" {
47
+ requestBody["proxy"] = proxy
48
+ // A blocked cached result must not mask an actual enhanced retry.
49
+ requestBody["maxAge"] = 0
50
+ }
51
+ if strings.HasSuffix(strings.ToLower(rawURL), ".pdf") {
52
+ requestBody["parsers"] = []string{"pdf"}
53
+ }
54
+ data, err := getFirecrawlQueue().enqueue(rawURL, func() (map[string]any, error) {
55
+ ctx, cancel := context.WithTimeout(context.Background(), 32*time.Second)
56
+ defer cancel()
57
+ body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/scrape", map[string]string{
58
+ "Authorization": "Bearer " + apiKey,
59
+ "Content-Type": "application/json",
60
+ }, requestBody)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ var parsed map[string]any
65
+ if err := json.Unmarshal(body, &parsed); err != nil {
66
+ return nil, fmt.Errorf("decode Firecrawl scrape: %w", err)
67
+ }
68
+ if success, _ := parsed["success"].(bool); !success {
69
+ return nil, fmt.Errorf("Firecrawl scrape failed: %v", parsed["error"])
70
+ }
71
+ return parsed, nil
72
+ })
73
+ if err != nil {
74
+ return scrapedPage{}, err
75
+ }
76
+ pageData, _ := data["data"].(map[string]any)
77
+ metadata, _ := pageData["metadata"].(map[string]any)
78
+ page := scrapedPage{
79
+ Title: stringValue(metadata["title"]),
80
+ Markdown: stringValue(pageData["markdown"]),
81
+ }
82
+ if status, ok := metadata["statusCode"].(float64); ok && status >= 400 {
83
+ return page, fmt.Errorf("source responded with HTTP %d", int(status))
84
+ }
85
+ return page, nil
86
+ }
87
+
88
+ func firecrawlJSON(ctx context.Context, method, endpoint, apiKey string, data map[string]any) (map[string]any, error) {
89
+ body, err := doJSONRequest(ctx, method, endpoint, map[string]string{
90
+ "Authorization": "Bearer " + apiKey,
91
+ "Content-Type": "application/json",
92
+ }, data)
93
+ if err != nil {
94
+ return nil, err
95
+ }
96
+ var parsed map[string]any
97
+ if err := json.Unmarshal(body, &parsed); err != nil {
98
+ return nil, err
99
+ }
100
+ if success, ok := parsed["success"].(bool); ok && !success {
101
+ return nil, fmt.Errorf("Firecrawl browser: %v", parsed["error"])
102
+ }
103
+ return parsed, nil
104
+ }
105
+
106
+ // quoteShellURL escapes a validated URL for the Firecrawl sandbox's bash mode.
107
+ // This invokes only the installed agent-browser CLI, never model-written code.
108
+ func quoteShellURL(rawURL string) string {
109
+ return "'" + strings.ReplaceAll(rawURL, "'", "'\\''") + "'"
110
+ }
111
+
112
+ func firecrawlBrowserFallback(rawURL, apiKey string) (string, string, error) {
113
+ ctx, cancel := context.WithTimeout(context.Background(), 28*time.Second)
114
+ defer cancel()
115
+ // Failed scrape responses do not reliably include a scrape-bound browser ID.
116
+ // Open a disposable session with the standalone browser API instead.
117
+ session, err := firecrawlJSON(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/interact", apiKey, map[string]any{
118
+ "ttl": 60, "activityTtl": 30,
119
+ })
120
+ if err != nil {
121
+ return "", "", err
122
+ }
123
+ sessionID := stringValue(session["id"])
124
+ if sessionID == "" {
125
+ return "", "", fmt.Errorf("Firecrawl browser returned no session ID")
126
+ }
127
+ endpoint := "https://api.firecrawl.dev/v2/interact/" + url.PathEscape(sessionID)
128
+ defer stopFirecrawlBrowser(endpoint, apiKey)
129
+ result, err := firecrawlJSON(ctx, http.MethodPost, endpoint+"/execute", apiKey, map[string]any{
130
+ // The sandbox's agent-browser does not expose a "scrape" subcommand
131
+ // even though Firecrawl's examples use it. DOM innerText is supported.
132
+ // Suppress navigation's banner so stdout contains only the evaluated text.
133
+ "code": "agent-browser open " + quoteShellURL(rawURL) + " >/dev/null && agent-browser eval 'document.body.innerText'",
134
+ "language": "bash",
135
+ "timeout": 20,
136
+ })
137
+ if err != nil {
138
+ return "", "", err
139
+ }
140
+ if code, ok := result["exitCode"].(float64); ok && code != 0 {
141
+ return "", "", fmt.Errorf("browser command exited with %d", int(code))
142
+ }
143
+ text := stringValue(result["stdout"])
144
+ if text == "" {
145
+ text = stringValue(result["output"])
146
+ }
147
+ if text == "" {
148
+ text = stringValue(result["result"])
149
+ }
150
+ // agent-browser eval serializes JS strings as JSON with surrounding quotes.
151
+ // Decode those escapes rather than handing the model a JSON string literal.
152
+ if parsed, err := strconv.Unquote(strings.TrimSpace(text)); err == nil {
153
+ text = parsed
154
+ }
155
+ return "Browser extraction", text, nil
156
+ }
157
+
158
+ func stopFirecrawlBrowser(endpoint, apiKey string) {
159
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
160
+ defer cancel()
161
+ // Best-effort cleanup regardless of whether extraction succeeded.
162
+ _, _ = firecrawlJSON(ctx, http.MethodDelete, endpoint, apiKey, nil)
163
+ }
@@ -0,0 +1,83 @@
1
+ package app
2
+
3
+ import (
4
+ "io"
5
+ "net/http"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestSourceURLSafety(t *testing.T) {
11
+ for _, raw := range []string{"file:///etc/passwd", "http://localhost:9999", "http://127.0.0.1:80", "http://[::1]", "http://user:pass@example.com", "http://192.168.0.1", "http://169.254.169.254"} {
12
+ if validateSourceURL(raw) == nil {
13
+ t.Fatalf("allowed unsafe URL %q", raw)
14
+ }
15
+ }
16
+ if err := validateSourceURL("https://example.com/guide"); err != nil {
17
+ t.Fatalf("rejected public URL: %v", err)
18
+ }
19
+ }
20
+
21
+ func TestScrapeEscalatesToEnhancedOnBlockedBody(t *testing.T) {
22
+ originalClient := http.DefaultClient
23
+ t.Cleanup(func() { http.DefaultClient = originalClient })
24
+ var proxies []string
25
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
26
+ if req.Method == http.MethodPost && req.URL.String() == "https://api.firecrawl.dev/v2/scrape" {
27
+ body, _ := io.ReadAll(req.Body)
28
+ if strings.Contains(string(body), `"proxy":"enhanced"`) {
29
+ proxies = append(proxies, "enhanced")
30
+ if !strings.Contains(string(body), `"maxAge":0`) {
31
+ t.Errorf("enhanced retry must bypass blocked cached results: %s", body)
32
+ }
33
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"metadata":{"title":"Article"},"markdown":"# Article\n\nLonger useful body from enhanced proxy."}}`, nil), nil
34
+ }
35
+ proxies = append(proxies, "auto")
36
+ if !strings.Contains(string(body), `"maxAge":1800000`) {
37
+ t.Errorf("Firecrawl cache age not 30 minutes: %s", body)
38
+ }
39
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"markdown":"Just a moment... checking your browser"}}`, nil), nil
40
+ }
41
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
42
+ })}
43
+ t.Setenv("FIRECRAWL_API_KEY", "test")
44
+ _, markdown, err := scrapeLinkWithFirecrawl("https://example.com/article")
45
+ if err != nil || !strings.Contains(markdown, "Longer useful body") {
46
+ t.Fatalf("expected enhanced proxy content, got %q: %v", markdown, err)
47
+ }
48
+ if strings.Join(proxies, ",") != "auto,enhanced" {
49
+ t.Fatalf("unexpected proxy stages %v", proxies)
50
+ }
51
+ }
52
+
53
+ func TestScrapeEscalatesToBrowserWhenScrapeFails(t *testing.T) {
54
+ originalClient := http.DefaultClient
55
+ t.Cleanup(func() { http.DefaultClient = originalClient })
56
+ var calls []string
57
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
58
+ calls = append(calls, req.Method+" "+req.URL.Path)
59
+ switch req.Method + " " + req.URL.Path {
60
+ case "POST /v2/scrape":
61
+ return testHTTPResponse(req, http.StatusOK, `{"success":false,"error":"blocked"}`, nil), nil
62
+ case "POST /v2/interact":
63
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"id":"abc123"}`, nil), nil
64
+ case "POST /v2/interact/abc123/execute":
65
+ body, _ := io.ReadAll(req.Body)
66
+ if !strings.Contains(string(body), "agent-browser eval") || strings.Contains(string(body), "agent-browser scrape") {
67
+ t.Errorf("missing browser extraction command: %s", body)
68
+ }
69
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"stdout":"\"Real page content from browser.\\nNew paragraph.\"","exitCode":0}`, nil), nil
70
+ case "DELETE /v2/interact/abc123":
71
+ return testHTTPResponse(req, http.StatusOK, `{}`, nil), nil
72
+ }
73
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
74
+ })}
75
+ t.Setenv("FIRECRAWL_API_KEY", "test")
76
+ _, markdown, err := scrapeLinkWithFirecrawl("https://example.com/dynamic")
77
+ if err != nil || !strings.Contains(markdown, "Real page content") || !strings.Contains(markdown, "browser.\nNew paragraph.") {
78
+ t.Fatalf("expected browser fallback content, got %q: %v", markdown, err)
79
+ }
80
+ if strings.Join(calls, ",") != "POST /v2/scrape,POST /v2/interact,POST /v2/interact/abc123/execute,DELETE /v2/interact/abc123" {
81
+ t.Fatalf("unexpected browser lifecycle: %v", calls)
82
+ }
83
+ }
@@ -759,11 +759,10 @@ func TestReadLinkUnsupportedGitHubFallsThroughToFirecrawlWithExistingSettings(t
759
759
  t.Fatalf("unsupported GitHub route was swallowed: %q", out)
760
760
  }
761
761
  for key, want := range map[string]any{
762
- "onlyMainContent": true,
763
- "skipTlsVerification": true,
764
- "blockAds": true,
765
- "removeBase64Images": true,
766
- "maxAge": float64(600000),
762
+ "onlyMainContent": true,
763
+ "blockAds": true,
764
+ "removeBase64Images": true,
765
+ "maxAge": float64(1800000),
767
766
  } {
768
767
  if got := firecrawlPayload[key]; got != want {
769
768
  t.Errorf("Firecrawl setting %s changed: got %#v want %#v", key, got, want)
@@ -0,0 +1,25 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "net"
6
+ "net/url"
7
+ "strings"
8
+ )
9
+
10
+ // Reject non-public URL destinations before calling native readers or
11
+ // hosted scraping providers. Credentials embedded in URLs are never accepted.
12
+ func validateSourceURL(raw string) error {
13
+ u, err := url.Parse(raw)
14
+ if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
15
+ return fmt.Errorf("readable URL must be an absolute HTTP(S) URL without credentials")
16
+ }
17
+ host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
18
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
19
+ return fmt.Errorf("private/internal host is not a readable public URL")
20
+ }
21
+ if ip := net.ParseIP(host); ip != nil && (!ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback()) {
22
+ return fmt.Errorf("private or reserved IP address is not a readable public URL")
23
+ }
24
+ return nil
25
+ }
@@ -131,6 +131,9 @@ func Search(params SearchParams) (string, error) {
131
131
  }
132
132
 
133
133
  func ReadLink(rawURL string) (string, error) {
134
+ if err := validateSourceURL(rawURL); err != nil {
135
+ return "", err
136
+ }
134
137
  native := readGitHubNative(rawURL)
135
138
  switch native.Outcome {
136
139
  case GitHubNativeSuccess:
@@ -193,55 +196,38 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
193
196
  if apiKey == "" {
194
197
  return "", "", missingCredentialsError("Error reading web page", []string{"FIRECRAWL_API_KEY"}, "for non-.md URLs")
195
198
  }
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
199
+ // Firecrawl's default auto proxy already tries basic then enhanced when the
200
+ // transport fails. A second explicit enhanced attempt is only useful when a
201
+ // scrape responds successfully but returns a challenge/empty document.
202
+ first, err := firecrawlScrape(rawURL, apiKey, "")
203
+ if err == nil && usableScrapedMarkdown(first.Markdown) {
204
+ return first.Title, first.Markdown, nil
205
+ }
206
+ lastError := err
207
+ if err == nil {
208
+ lastError = fmt.Errorf("Firecrawl returned empty or blocked page content")
209
+ second, enhancedErr := firecrawlScrape(rawURL, apiKey, "enhanced")
210
+ if enhancedErr == nil && usableScrapedMarkdown(second.Markdown) {
211
+ return second.Title, second.Markdown, nil
224
212
  }
225
- if success, _ := parsed["success"].(bool); !success {
226
- return nil, fmt.Errorf("Scraping failed for %s: %v", rawURL, parsed["error"])
213
+ if enhancedErr != nil {
214
+ lastError = enhancedErr
227
215
  }
228
- return parsed, nil
229
- })
230
- if err != nil {
231
- return "", "", fmt.Errorf("Error reading web page: %v", err)
232
216
  }
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"
217
+ title, markdown, browserErr := firecrawlBrowserFallback(rawURL, apiKey)
218
+ if browserErr == nil && usableScrapedMarkdown(markdown) {
219
+ return title, markdown, nil
220
+ }
221
+ if browserErr == nil {
222
+ browserErr = fmt.Errorf("browser produced no usable content")
240
223
  }
241
- return title, markdown, nil
224
+ return "", "", fmt.Errorf("Error reading web page: scrape: %v; browser fallback: %w", lastError, browserErr)
242
225
  }
243
226
 
244
227
  func MapSite(rawURL string) (string, error) {
228
+ if err := validateSourceURL(rawURL); err != nil {
229
+ return "", err
230
+ }
245
231
  apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
246
232
  if apiKey == "" {
247
233
  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.2",
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,79 @@
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
+ func makeServer() *mcp.Server {
22
+ s := mcp.NewServer(&mcp.Implementation{Name: "origo", Version: "0.1.0"}, nil)
23
+ mcp.AddTool(s, &mcp.Tool{
24
+ Name: "read_link",
25
+ Title: "Read link",
26
+ Description: "Read a specific web page as clean Markdown, preferring native GitHub or site Markdown and using Firecrawl proxies/browser fallback when needed. Does not search the web.",
27
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
28
+ result, err := retrieval.ReadLink(input.URL)
29
+ if err != nil {
30
+ return nil, nil, err
31
+ }
32
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
33
+ })
34
+ mcp.AddTool(s, &mcp.Tool{
35
+ Name: "map_site",
36
+ Title: "Map site",
37
+ Description: "Discover URLs on a site using Firecrawl's sitemap-based map API. Does not perform web search.",
38
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
39
+ result, err := retrieval.MapSite(input.URL)
40
+ if err != nil {
41
+ return nil, nil, err
42
+ }
43
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil, nil
44
+ })
45
+ return s
46
+ }
47
+
48
+ var transport = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
49
+ return makeServer()
50
+ }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
51
+
52
+ func Handler() http.Handler {
53
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54
+ w.Header().Set("Cache-Control", "no-store")
55
+ w.Header().Set("Referrer-Policy", "no-referrer")
56
+ w.Header().Set("X-Content-Type-Options", "nosniff")
57
+ w.Header().Set("X-Robots-Tag", "noindex")
58
+ secret := strings.TrimSpace(os.Getenv("ORIGO_API_KEY"))
59
+ if secret == "" {
60
+ http.Error(w, "MCP endpoint not configured", http.StatusServiceUnavailable)
61
+ return
62
+ }
63
+ keys := r.URL.Query()["key"]
64
+ if len(keys) != 1 || !validKey(secret, keys[0]) {
65
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
66
+ return
67
+ }
68
+ transport.ServeHTTP(w, r)
69
+ })
70
+ }
71
+
72
+ func validKey(expected, supplied string) bool {
73
+ if supplied == "" {
74
+ return false
75
+ }
76
+ want := sha256.Sum256([]byte(expected))
77
+ got := sha256.Sum256([]byte(supplied))
78
+ return subtle.ConstantTimeCompare(want[:], got[:]) == 1
79
+ }
@@ -0,0 +1,63 @@
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
+ }
@@ -0,0 +1,11 @@
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
+ func MapSite(url string) (string, error) { return app.MapSite(url) }