Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<h1 align="center">degoog-mcp</h1><br/>
</p>

Lightweight Go sidecar that exposes [Degoog](../README.md) to LLMs via the [Model Context Protocol](https://modelcontextprotocol.io). Speaks modern MCP Streamable HTTP at `/mcp`, keeps legacy SSE for older clients, runs in a tiny `scratch` container, and gives any MCP-capable client two tools:
Lightweight Go sidecar that exposes [Degoog](../README.md) to LLMs via the [Model Context Protocol](https://modelcontextprotocol.io). Speaks modern MCP Streamable HTTP at `/mcp`, runs in a tiny `scratch` container, and gives any MCP-capable client two tools:

- **`search`** - fast meta-search, returns a concise text summary plus structured URLs, snippets, engine timings, cap metadata, and source overlap.
- **`scrape`** - fetches URLs concurrently, returns clean Markdown plus one structured row per requested URL, including explicit error rows for failures.
Expand All @@ -26,14 +26,15 @@ Lightweight Go sidecar that exposes [Degoog](../README.md) to LLMs via the [Mode

## Run

Listens on `4443` by default. Modern MCP endpoint at `/mcp`, legacy SSE at `/sse` and `/`, healthcheck at `/healthz`. Config via `DEGOOG_MCP_*` env vars:
Listens on `4443` by default. Modern MCP endpoint at `/mcp`, healthcheck at `/healthz`. Config via `DEGOOG_MCP_*` env vars:

| Variable | Default | Notes |
| :---------------------------------- | :------------------------- | :----------------------------------------------------------------- |
| `DEGOOG_MCP_BIND_HOST` | _(empty)_ | Optional bind host. Use `127.0.0.1` for local-only deployments. |
| `DEGOOG_MCP_PORT` | `4443` | HTTP listen port. |
| `DEGOOG_MCP_DEGOOG_URL` | `http://degoog:4444` | Where the Degoog aggregator lives. Default assumes shared compose. |
| `DEGOOG_MCP_API_KEY` | _(empty)_ | Optional Bearer token sent to Degoog as an Authorization header. |
| `DEGOOG_MCP_DEGOOG_API_KEY` | _(empty)_ | Optional Bearer token sent to Degoog as an Authorization header. |
| `DEGOOG_MCP_AUTH_TOKEN` | _(empty)_ | Optional inbound bearer token clients must present on `/mcp`. Empty = `/mcp` is open. `/healthz` is always open. |
| `DEGOOG_MCP_TIMEOUT` | `15s` | Per-request timeout for both Degoog calls and scraped URLs. |
| `DEGOOG_MCP_MAX_RESULTS` | `0` | Cap on merged `search` results (top-scored kept). `0` = no cap. Trims context for small-window models. Overridable per call. |
| `DEGOOG_MCP_ENGINES` | _(empty)_ | Comma-separated engine ids to restrict every `search` to (e.g. `brave,duckduckgo`). Empty = instance defaults. Overridable per call. |
Expand All @@ -48,8 +49,6 @@ Listens on `4443` by default. Modern MCP endpoint at `/mcp`, legacy SSE at `/sse

The scraper accepts only `http` and `https` URLs, resolves DNS before dialing, blocks private and local IP ranges, and repeats the checks on redirects.

| `DEGOOG_MCP_API_KEY` | _(empty)_ | Optional Bearer token sent to Degoog as an Authorization header. |

Valid engine ids for `DEGOOG_MCP_ENGINES` (and the per-call `engines` argument) come from your instance: `GET /api/extensions?type=engine` lists them. Running a second Degoog instance with a single engine enabled is no longer necessary; restrict from the MCP side instead.

<details>
Expand All @@ -63,7 +62,8 @@ services:
- "4443:4443"
environment:
DEGOOG_MCP_DEGOOG_URL: "http://<your-degoog-host>:4444"
| `DEGOOG_MCP_API_KEY` | _(empty)_ | Optional Bearer token sent to Degoog as an Authorization header. |
# Optional: require clients to send Authorization: Bearer <token> to /mcp
DEGOOG_MCP_AUTH_TOKEN: ""
DEGOOG_MCP_BIND_HOST: ""
restart: unless-stopped
```
Expand Down Expand Up @@ -107,7 +107,9 @@ Modern Streamable HTTP endpoint: `http://localhost:4443/mcp`

If your MCP host prefixes tool names with the server name, name the server `degoog` rather than an environment-specific label. That keeps exposed names short and obvious, e.g. `mcp_degoog_search` and `mcp_degoog_scrape`.

Legacy SSE endpoint: `http://localhost:4443/sse` (`/` is also kept for older users).
### Auth

When `DEGOOG_MCP_AUTH_TOKEN` is set, every request to `/mcp` must carry `Authorization: Bearer <token>`. Missing, malformed, or wrong tokens get a `401` with a `WWW-Authenticate: Bearer` header. `/healthz` stays open so container health checks keep working. Leave the variable empty to keep `/mcp` open. For clients that support custom HTTP headers, add the bearer header (examples below).

<details>
<summary>Claude Desktop / current Claude</summary>
Expand All @@ -125,6 +127,22 @@ Use HTTP transport where your Claude client supports remote MCP servers:
}
```

If you set `DEGOOG_MCP_AUTH_TOKEN`, add the bearer header:

```json
{
"mcpServers": {
"degoog": {
"type": "http",
"url": "http://localhost:4443/mcp",
"headers": {
"Authorization": "Bearer <your-token>"
}
}
}
}
```

For stdio-only Claude Desktop builds, use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge. Edit `claude_desktop_config.json` (Settings -> Developer -> Edit Config):

```json
Expand Down Expand Up @@ -184,9 +202,9 @@ Most editors that speak MCP accept a config block like:
}
```

For stdio-only clients, wrap with `npx mcp-remote http://localhost:4443/mcp` the same way Claude Desktop does above.
If you set `DEGOOG_MCP_AUTH_TOKEN`, add an `Authorization: Bearer <token>` header where your client supports custom HTTP headers.

Legacy SSE clients can use `http://localhost:4443/sse` with `transport: "sse"`. New clients should use `/mcp`.
For stdio-only clients, wrap with `npx mcp-remote http://localhost:4443/mcp` the same way Claude Desktop does above.

</details>

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ services:
DEGOOG_MCP_CACHE_SIZE_MB: "64"
DEGOOG_MCP_LOG_LEVEL: "info"
DEGOOG_MCP_DEGOOG_URL: "http://192.168.86.233:4444"
DEGOOG_MCP_API_KEY: ""
DEGOOG_MCP_DEGOOG_API_KEY: ""
DEGOOG_MCP_USER_AGENT: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
5 changes: 4 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ const (
ENV_CACHE_SIZE = "DEGOOG_MCP_CACHE_SIZE_MB"
ENV_USER_AGENT = "DEGOOG_MCP_USER_AGENT"
ENV_DEGOOG_URL = "DEGOOG_MCP_DEGOOG_URL"
ENV_API_KEY = "DEGOOG_MCP_API_KEY"
ENV_API_KEY = "DEGOOG_MCP_DEGOOG_API_KEY"
ENV_MAX_RESULTS = "DEGOOG_MCP_MAX_RESULTS"
ENV_ENGINES = "DEGOOG_MCP_ENGINES"
ENV_AUTH_TOKEN = "DEGOOG_MCP_AUTH_TOKEN"

DEFAULT_BIND_HOST = ""
DEFAULT_PORT = "4443"
Expand Down Expand Up @@ -56,6 +57,7 @@ type Config struct {
APIKey string
MaxResults int
Engines []string
AuthToken string
}

func Load() *Config {
Expand All @@ -74,6 +76,7 @@ func Load() *Config {
APIKey: readStr(ENV_API_KEY, ""),
MaxResults: readNonNeg(ENV_MAX_RESULTS, DEFAULT_MAX_RESULTS),
Engines: readList(ENV_ENGINES),
AuthToken: strings.TrimSpace(os.Getenv(ENV_AUTH_TOKEN)),
}
}

Expand Down
65 changes: 46 additions & 19 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package main

import (
"context"
"crypto/sha256"
"crypto/subtle"
"errors"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -20,17 +23,20 @@ import (
)

const (
SERVER_NAME = "degoog-mcp"
SERVER_VERSION = "0.2.0"
SHUTDOWN_WAIT = 5 * time.Second
ROUTE_MCP = "/mcp"
ROUTE_SSE = "/sse"
ROUTE_LEGACY = "/"
ROUTE_HEALTH = "/healthz"
HEALTH_BODY = "ok"
READ_TIMEOUT = 30 * time.Second
WRITE_TIMEOUT = 0
IDLE_TIMEOUT = 120 * time.Second
SERVER_NAME = "degoog-mcp"
SERVER_VERSION = "0.2.0"
SHUTDOWN_WAIT = 5 * time.Second
ROUTE_MCP = "/mcp"
ROUTE_HEALTH = "/healthz"
HEALTH_BODY = "ok"
READ_TIMEOUT = 30 * time.Second
WRITE_TIMEOUT = 0
IDLE_TIMEOUT = 120 * time.Second
HEADER_AUTHZ = "Authorization"
HEADER_WWW_AUTH = "WWW-Authenticate"
BEARER_PREFIX = "Bearer "
WWW_AUTH_VALUE = "Bearer"
DENIED_BODY = "unauthorized\n"
)

func main() {
Expand All @@ -57,7 +63,10 @@ func main() {
srv := mcp.NewServer(&mcp.Implementation{Name: SERVER_NAME, Version: SERVER_VERSION}, nil)
commands.Register(srv, sc, dg, cfg)

mux := buildMux(srv, log)
mux := buildMux(srv, cfg, log)
if cfg.AuthToken != "" {
log.Info("auth: inbound bearer auth enabled for %s", ROUTE_MCP)
}

httpSrv := &http.Server{
Addr: listenAddr(cfg),
Expand Down Expand Up @@ -97,14 +106,11 @@ func listenAddr(cfg *config.Config) string {
return cfg.BindHost + ":" + cfg.Port
}

func buildMux(srv *mcp.Server, log *logger.Logger) *http.ServeMux {
func buildMux(srv *mcp.Server, cfg *config.Config, log *logger.Logger) *http.ServeMux {
mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil)
sseHandler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return srv }, nil)

mux := http.NewServeMux()
mux.Handle(ROUTE_MCP, mcpHandler)
mux.Handle(ROUTE_SSE, sseHandler)
mux.Handle(ROUTE_LEGACY, legacySSE(sseHandler, log))
mux.Handle(ROUTE_MCP, bouncer(mcpHandler, cfg.AuthToken, log))
mux.HandleFunc(ROUTE_HEALTH, func(w http.ResponseWriter, r *http.Request) {
if _, werr := w.Write([]byte(HEALTH_BODY)); werr != nil {
log.Warn("health: write failed: %v", werr)
Expand All @@ -113,13 +119,34 @@ func buildMux(srv *mcp.Server, log *logger.Logger) *http.ServeMux {
return mux
}

func legacySSE(next http.Handler, log *logger.Logger) http.Handler {
func bouncer(next http.Handler, token string, log *logger.Logger) http.Handler {
if token == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Warn("http: legacy sse endpoint used path=%s", r.URL.Path)
if !tokenOK(r.Header.Get(HEADER_AUTHZ), token) {
log.Warn("auth: rejected request to %s", r.URL.Path)
w.Header().Set(HEADER_WWW_AUTH, WWW_AUTH_VALUE)
w.WriteHeader(http.StatusUnauthorized)
if _, werr := w.Write([]byte(DENIED_BODY)); werr != nil {
log.Warn("auth: write failed: %v", werr)
}
return
}
next.ServeHTTP(w, r)
})
}

func tokenOK(header, token string) bool {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], strings.TrimSpace(BEARER_PREFIX)) {
return false
}
gotHash := sha256.Sum256([]byte(parts[1]))
tokenHash := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(gotHash[:], tokenHash[:]) == 1
}

func lightsOut(srv *http.Server, log *logger.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), SHUTDOWN_WAIT)
defer cancel()
Expand Down
134 changes: 110 additions & 24 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,44 +1,130 @@
package main

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"

"degoog-mcp/internal/config"
"degoog-mcp/internal/logger"
)

func TestBuildMuxRoutes(t *testing.T) {
srv := mcp.NewServer(&mcp.Implementation{Name: SERVER_NAME, Version: SERVER_VERSION}, nil)
mux := buildMux(srv, logger.Get())
func newServer() *mcp.Server {
return mcp.NewServer(&mcp.Implementation{Name: SERVER_NAME, Version: SERVER_VERSION}, nil)
}

func TestHealthOpen(t *testing.T) {
mux := buildMux(newServer(), &config.Config{}, logger.Get())

health := httptest.NewRecorder()
mux.ServeHTTP(health, httptest.NewRequest(http.MethodGet, ROUTE_HEALTH, nil))
if health.Code != http.StatusOK {
t.Fatalf("health status: want 200, got %d", health.Code)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, ROUTE_HEALTH, nil))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if rec.Code != http.StatusOK {
t.Fatalf("health status: want 200, got %d", rec.Code)
}
if health.Body.String() != HEALTH_BODY {
t.Fatalf("health body: want %q, got %q", HEALTH_BODY, health.Body.String())
if rec.Body.String() != HEALTH_BODY {
t.Fatalf("health body: want %q, got %q", HEALTH_BODY, rec.Body.String())
}
}

cases := []struct {
name string
path string
want string
}{
{name: "modern", path: ROUTE_MCP, want: ROUTE_MCP},
{name: "sse", path: ROUTE_SSE, want: ROUTE_SSE},
{name: "legacy", path: ROUTE_LEGACY, want: ROUTE_LEGACY},
func TestHealthOpenWithToken(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, ROUTE_HEALTH, nil))
if rec.Code != http.StatusOK {
t.Fatalf("health should stay open with token set: got %d", rec.Code)
}
}

func TestMcpReachableNoToken(t *testing.T) {
mux := buildMux(newServer(), &config.Config{}, logger.Get())

rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, ROUTE_MCP, nil))
if rec.Code == http.StatusUnauthorized {
t.Fatalf("no token configured should not auth-block /mcp, got 401")
}
}

func TestMcpMissingAuth(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, ROUTE_MCP, nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("missing auth: want 401, got %d", rec.Code)
}
assertWWWAuthenticate(t, rec)
}

func TestMcpMalformedAuth(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

req := httptest.NewRequest(http.MethodPost, ROUTE_MCP, nil)
req.Header.Set(HEADER_AUTHZ, "s3cret")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("malformed auth (no bearer prefix): want 401, got %d", rec.Code)
}
assertWWWAuthenticate(t, rec)
}

func TestMcpWrongToken(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

req := httptest.NewRequest(http.MethodPost, ROUTE_MCP, nil)
req.Header.Set(HEADER_AUTHZ, BEARER_PREFIX+"nope")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong token: want 401, got %d", rec.Code)
}
assertWWWAuthenticate(t, rec)
}

func TestMcpCorrectToken(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

req := httptest.NewRequest(http.MethodGet, ROUTE_MCP, nil)
req.Header.Set(HEADER_AUTHZ, BEARER_PREFIX+"s3cret")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code == http.StatusUnauthorized {
t.Fatalf("correct token should pass through to /mcp handler, got 401")
}
}

func TestMcpCorrectTokenIsCaseAndWhitespaceTolerant(t *testing.T) {
mux := buildMux(newServer(), &config.Config{AuthToken: "s3cret"}, logger.Get())

req := httptest.NewRequest(http.MethodGet, ROUTE_MCP, nil)
req.Header.Set(HEADER_AUTHZ, "bearer s3cret")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code == http.StatusUnauthorized {
t.Fatalf("case-insensitive bearer scheme with extra spaces should pass through to /mcp handler, got 401")
}
}

func assertWWWAuthenticate(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if got := rec.Header().Get(HEADER_WWW_AUTH); got != WWW_AUTH_VALUE {
t.Fatalf("WWW-Authenticate: want %q, got %q", WWW_AUTH_VALUE, got)
}
}

func TestLegacyRoutesGone(t *testing.T) {
mux := buildMux(newServer(), &config.Config{}, logger.Get())

for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, pattern := mux.Handler(httptest.NewRequest(http.MethodGet, tt.path, nil))
if pattern != tt.want {
t.Fatalf("route pattern: want %q, got %q", tt.want, pattern)
}
})
for _, path := range []string{"/sse", "/"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("legacy path %q: want 404, got %d", path, rec.Code)
}
}
}
Loading
Loading