Merge pull request #180 from googio/feat/serply-search
feat: add optional serply search template to mcp-bridge
3 files · +99 -0
M examples/extensions/mcp-bridge/README.md +56 -0
| @@ -229,6 +229,15 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||
| 229 | 229 | "you": { | |
| 230 | 230 | "transport": "streamable-http", | |
| 231 | 231 | "url": "https://api.you.com/mcp?profile=free" | |
| 232 | + | }, | |
| 233 | + | ||
| 234 | + | // Serply Google-backed search (streamable-http, keyed) | |
| 235 | + | "serply": { | |
| 236 | + | "transport": "streamable-http", | |
| 237 | + | "url": "https://api.serply.io/mcp", | |
| 238 | + | "headers": { | |
| 239 | + | "X-Api-Key": "${SERPLY_API_KEY}" | |
| 240 | + | } | |
| 232 | 241 | } | |
| 233 | 242 | } | |
| 234 | 243 | } | |
| @@ -258,6 +267,53 @@ the template: | |||
| 258 | 267 | The authenticated endpoint does not expose `you-finance` by default. Request | |
| 259 | 268 | it explicitly with the `?tools=` URL parameter or the `X-Allowed-Tools` header. | |
| 260 | 269 | ||
| 270 | + | ### Serply template | |
| 271 | + | ||
| 272 | + | `/mcp setup add serply` registers the [Serply](https://serply.io) MCP server, | |
| 273 | + | which returns Google-backed results. It has no keyless profile, so the template | |
| 274 | + | is the first one that writes a `headers` entry: | |
| 275 | + | ||
| 276 | + | ```jsonc | |
| 277 | + | { | |
| 278 | + | "mcpServers": { | |
| 279 | + | "serply": { | |
| 280 | + | "transport": "streamable-http", | |
| 281 | + | "url": "https://api.serply.io/mcp", | |
| 282 | + | "headers": { | |
| 283 | + | "X-Api-Key": "${SERPLY_API_KEY}" | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ``` | |
| 289 | + | ||
| 290 | + | The template writes the variable reference, not the key, so `mcp.json` holds no | |
| 291 | + | secret and stays safe to commit or sync. The bridge expands it from its own | |
| 292 | + | environment at load time (see [Environment variables](#environment-variables)); | |
| 293 | + | with `SERPLY_API_KEY` unset, this server is disabled and reported by name while | |
| 294 | + | every other server keeps working. Get a key at | |
| 295 | + | [serply.io](https://serply.io); the API reference is at | |
| 296 | + | [serply.io/docs](https://serply.io/docs). | |
| 297 | + | ||
| 298 | + | The server exposes 14 tools, so it covers several verticals rather than one | |
| 299 | + | general search: | |
| 300 | + | ||
| 301 | + | | tool | returns | | |
| 302 | + | |------|---------| | |
| 303 | + | | `google_search` | Google organic results | | |
| 304 | + | | `google_news_search` | Google News | | |
| 305 | + | | `google_scholar_search` | Google Scholar, including citation counts | | |
| 306 | + | | `google_jobs_search` | Google Jobs | | |
| 307 | + | | `google_maps_search` | Google Maps places | | |
| 308 | + | | `google_video_search` | Google video results | | |
| 309 | + | | `bing_search` | Bing organic results | | |
| 310 | + | | `amazon_product_search` | Amazon product listings | | |
| 311 | + | | `scrape_url` | a URL's content through the proxy layer | | |
| 312 | + | | `reddit_subreddit_posts`, `reddit_subreddit_about`, `reddit_user_posts`, `reddit_post_comments`, `reddit_post` | Reddit subreddits, users, posts, and comments | | |
| 313 | + | ||
| 314 | + | They reach the model as `mcp__serply__google_scholar_search` and so on, per | |
| 315 | + | [Tool Naming](#tool-naming). | |
| 316 | + | ||
| 261 | 317 | ## How It Works | |
| 262 | 318 | ||
| 263 | 319 | ``` | |
| @@ -229,6 +229,15 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||||
| 229 | "you": { | 229 | "you": { | ||
| 230 | "transport": "streamable-http", | 230 | "transport": "streamable-http", | ||
| 231 | "url": "https://api.you.com/mcp?profile=free" | 231 | "url": "https://api.you.com/mcp?profile=free" | ||
| 232 | + | }, | |||
| 233 | + | ||||
| 234 | + | // Serply Google-backed search (streamable-http, keyed) | |||
| 235 | + | "serply": { | |||
| 236 | + | "transport": "streamable-http", | |||
| 237 | + | "url": "https://api.serply.io/mcp", | |||
| 238 | + | "headers": { | |||
| 239 | + | "X-Api-Key": "${SERPLY_API_KEY}" | |||
| 240 | + | } | |||
| 232 | } | 241 | } | ||
| 233 | } | 242 | } | ||
| 234 | } | 243 | } | ||
| @@ -258,6 +267,53 @@ the template: | |||||
| 258 | The authenticated endpoint does not expose `you-finance` by default. Request | 267 | The authenticated endpoint does not expose `you-finance` by default. Request | ||
| 259 | it explicitly with the `?tools=` URL parameter or the `X-Allowed-Tools` header. | 268 | it explicitly with the `?tools=` URL parameter or the `X-Allowed-Tools` header. | ||
| 260 | 269 | ||||
| 270 | + | ### Serply template | |||
| 271 | + | ||||
| 272 | + | `/mcp setup add serply` registers the [Serply](https://serply.io) MCP server, | |||
| 273 | + | which returns Google-backed results. It has no keyless profile, so the template | |||
| 274 | + | is the first one that writes a `headers` entry: | |||
| 275 | + | ||||
| 276 | + | ```jsonc | |||
| 277 | + | { | |||
| 278 | + | "mcpServers": { | |||
| 279 | + | "serply": { | |||
| 280 | + | "transport": "streamable-http", | |||
| 281 | + | "url": "https://api.serply.io/mcp", | |||
| 282 | + | "headers": { | |||
| 283 | + | "X-Api-Key": "${SERPLY_API_KEY}" | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | ``` | |||
| 289 | + | ||||
| 290 | + | The template writes the variable reference, not the key, so `mcp.json` holds no | |||
| 291 | + | secret and stays safe to commit or sync. The bridge expands it from its own | |||
| 292 | + | environment at load time (see [Environment variables](#environment-variables)); | |||
| 293 | + | with `SERPLY_API_KEY` unset, this server is disabled and reported by name while | |||
| 294 | + | every other server keeps working. Get a key at | |||
| 295 | + | [serply.io](https://serply.io); the API reference is at | |||
| 296 | + | [serply.io/docs](https://serply.io/docs). | |||
| 297 | + | ||||
| 298 | + | The server exposes 14 tools, so it covers several verticals rather than one | |||
| 299 | + | general search: | |||
| 300 | + | ||||
| 301 | + | | tool | returns | | |||
| 302 | + | |------|---------| | |||
| 303 | + | | `google_search` | Google organic results | | |||
| 304 | + | | `google_news_search` | Google News | | |||
| 305 | + | | `google_scholar_search` | Google Scholar, including citation counts | | |||
| 306 | + | | `google_jobs_search` | Google Jobs | | |||
| 307 | + | | `google_maps_search` | Google Maps places | | |||
| 308 | + | | `google_video_search` | Google video results | | |||
| 309 | + | | `bing_search` | Bing organic results | | |||
| 310 | + | | `amazon_product_search` | Amazon product listings | | |||
| 311 | + | | `scrape_url` | a URL's content through the proxy layer | | |||
| 312 | + | | `reddit_subreddit_posts`, `reddit_subreddit_about`, `reddit_user_posts`, `reddit_post_comments`, `reddit_post` | Reddit subreddits, users, posts, and comments | | |||
| 313 | + | ||||
| 314 | + | They reach the model as `mcp__serply__google_scholar_search` and so on, per | |||
| 315 | + | [Tool Naming](#tool-naming). | |||
| 316 | + | ||||
| 261 | ## How It Works | 317 | ## How It Works | ||
| 262 | 318 | ||||
| 263 | ``` | 319 | ``` | ||
M examples/extensions/mcp-bridge/setup.go +11 -0
| @@ -72,6 +72,17 @@ func setupTemplates() map[string]serverTemplate { | |||
| 72 | 72 | } | |
| 73 | 73 | }, | |
| 74 | 74 | }, | |
| 75 | + | "serply": { | |
| 76 | + | Name: "serply", | |
| 77 | + | Description: "Google-backed search via the Serply MCP server: web, news, and scholar results with citation counts, plus maps, jobs, video, Bing, Amazon products, Reddit, and URL scraping. Requires a SERPLY_API_KEY.", | |
| 78 | + | Config: func(cwd string) ServerConfig { | |
| 79 | + | return ServerConfig{ | |
| 80 | + | Transport: "streamable-http", | |
| 81 | + | URL: "https://api.serply.io/mcp", | |
| 82 | + | Headers: map[string]string{"X-Api-Key": "${SERPLY_API_KEY}"}, | |
| 83 | + | } | |
| 84 | + | }, | |
| 85 | + | }, | |
| 75 | 86 | } | |
| 76 | 87 | } | |
| 77 | 88 | ||
| @@ -72,6 +72,17 @@ func setupTemplates() map[string]serverTemplate { | |||||
| 72 | } | 72 | } | ||
| 73 | }, | 73 | }, | ||
| 74 | }, | 74 | }, | ||
| 75 | + | "serply": { | |||
| 76 | + | Name: "serply", | |||
| 77 | + | Description: "Google-backed search via the Serply MCP server: web, news, and scholar results with citation counts, plus maps, jobs, video, Bing, Amazon products, Reddit, and URL scraping. Requires a SERPLY_API_KEY.", | |||
| 78 | + | Config: func(cwd string) ServerConfig { | |||
| 79 | + | return ServerConfig{ | |||
| 80 | + | Transport: "streamable-http", | |||
| 81 | + | URL: "https://api.serply.io/mcp", | |||
| 82 | + | Headers: map[string]string{"X-Api-Key": "${SERPLY_API_KEY}"}, | |||
| 83 | + | } | |||
| 84 | + | }, | |||
| 85 | + | }, | |||
| 75 | } | 86 | } | ||
| 76 | } | 87 | } | ||
| 77 | 88 | ||||
M examples/extensions/mcp-bridge/setup_test.go +32 -0
| @@ -87,6 +87,38 @@ func TestHandleSetupAddYouGlobal(t *testing.T) { | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | func TestHandleSetupAddSerplyGlobal(t *testing.T) { | |
| 91 | + | tmp := t.TempDir() | |
| 92 | + | t.Setenv("ZOT_HOME", tmp) | |
| 93 | + | ||
| 94 | + | out, err := handleSetup([]string{"add", "serply"}, tmp) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatalf("handleSetup add serply: %v", err) | |
| 97 | + | } | |
| 98 | + | if out == "" { | |
| 99 | + | t.Fatal("expected setup output") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | data, err := os.ReadFile(filepath.Join(tmp, "mcp.json")) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatalf("read mcp.json: %v", err) | |
| 105 | + | } | |
| 106 | + | var cfg Config | |
| 107 | + | if err := json.Unmarshal(data, &cfg); err != nil { | |
| 108 | + | t.Fatalf("parse mcp.json: %v", err) | |
| 109 | + | } | |
| 110 | + | serply := cfg.MCPServers["serply"] | |
| 111 | + | if serply.Transport != "streamable-http" || serply.URL != "https://api.serply.io/mcp" { | |
| 112 | + | t.Fatalf("unexpected serply config: %+v", serply) | |
| 113 | + | } | |
| 114 | + | // The keyed template must reference the key by variable so the written | |
| 115 | + | // config carries no secret. expandServerEnv resolves it at load time, and | |
| 116 | + | // drops the server entirely when SERPLY_API_KEY is unset. | |
| 117 | + | if got := serply.Headers["X-Api-Key"]; got != "${SERPLY_API_KEY}" { | |
| 118 | + | t.Fatalf("api key must stay an unexpanded variable reference, got %q", got) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 90 | 122 | func TestHandleSetupDuplicate(t *testing.T) { | |
| 91 | 123 | tmp := t.TempDir() | |
| 92 | 124 | t.Setenv("ZOT_HOME", tmp) | |
| @@ -87,6 +87,38 @@ func TestHandleSetupAddYouGlobal(t *testing.T) { | |||||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | func TestHandleSetupAddSerplyGlobal(t *testing.T) { | |||
| 91 | + | tmp := t.TempDir() | |||
| 92 | + | t.Setenv("ZOT_HOME", tmp) | |||
| 93 | + | ||||
| 94 | + | out, err := handleSetup([]string{"add", "serply"}, tmp) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatalf("handleSetup add serply: %v", err) | |||
| 97 | + | } | |||
| 98 | + | if out == "" { | |||
| 99 | + | t.Fatal("expected setup output") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | data, err := os.ReadFile(filepath.Join(tmp, "mcp.json")) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatalf("read mcp.json: %v", err) | |||
| 105 | + | } | |||
| 106 | + | var cfg Config | |||
| 107 | + | if err := json.Unmarshal(data, &cfg); err != nil { | |||
| 108 | + | t.Fatalf("parse mcp.json: %v", err) | |||
| 109 | + | } | |||
| 110 | + | serply := cfg.MCPServers["serply"] | |||
| 111 | + | if serply.Transport != "streamable-http" || serply.URL != "https://api.serply.io/mcp" { | |||
| 112 | + | t.Fatalf("unexpected serply config: %+v", serply) | |||
| 113 | + | } | |||
| 114 | + | // The keyed template must reference the key by variable so the written | |||
| 115 | + | // config carries no secret. expandServerEnv resolves it at load time, and | |||
| 116 | + | // drops the server entirely when SERPLY_API_KEY is unset. | |||
| 117 | + | if got := serply.Headers["X-Api-Key"]; got != "${SERPLY_API_KEY}" { | |||
| 118 | + | t.Fatalf("api key must stay an unexpanded variable reference, got %q", got) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 90 | func TestHandleSetupDuplicate(t *testing.T) { | 122 | func TestHandleSetupDuplicate(t *testing.T) { | ||
| 91 | tmp := t.TempDir() | 123 | tmp := t.TempDir() | ||
| 92 | t.Setenv("ZOT_HOME", tmp) | 124 | t.Setenv("ZOT_HOME", tmp) | ||