Department Web-Search MCP Gateway
Department Web-Search MCP Gateway
A self-hosted web-search service the whole department can share. It reuses a
single logged-in browser session (a shared service account), so intranet /
SSO / consent-wall logins are handled once — every client just calls a
web_search tool, no per-user login or API key.
Any MCP client connects to one URL:
Chatbox (≥1.14)
OpenCode — local, on a shared server, or via vscode-remote
Claude Code (and other coding agents that speak MCP)
It is the T1 “centralized search gateway” from the research notes: one internal machine + one shared Chrome profile + one HTTP MCP endpoint.
How it works
Chatbox / OpenCode(local|server|vscode-remote) / Claude Code
│ remote MCP (Streamable HTTP, /mcp) — same URL for everyone
▼
┌──────────────────────────────────────────────┐
│ Gateway (this service, Node + Express) │
│ • Bearer token (optional) + Host validation │
│ • MCP tools: web_search / read_webpage │
└──────────────────────────────────────────────┘
│ connectOverCDP / launchPersistentContext
▼
┌──────────────────────────────────────────────┐
│ Chrome (persistent profile, shared account) │ ← logged in ONCE via `npm run login`
│ • per-request new tab (isolation) │
│ • concurrency cap + timeouts │
└──────────────────────────────────────────────┘
│ optional fallback
▼
SearXNG (if SEARXNG_URL set) — public-search fallback when browser returns nothingcreateMcpHandler serves both 2025-era and 2026-era MCP clients on the same
/mcp endpoint, so client transport compatibility is not a concern.
Headless Linux servers + a Windows PC for login
Servers have no GUI, but a human can log in on a Windows PC. Pick a mode in
.env (BROWSER_MODE) — the code is identical, only config differs.
⚠️ Do NOT copy a Windows Chrome profile directory to Linux. Chromium encrypts cookies with OS-bound keys (DPAPI on Windows, keyring/”peanuts” on Linux), so a copied profile loses the login silently. Use one of the cross-OS-safe modes below.
Mode C — BROWSER_MODE=cdp (recommended): Linux gateway attaches to the Windows browser
Windows PC (stays on): log in once with the shared account, then keep Chrome running with a local-only debug port:
chrome --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 ^ --user-data-dir=C:\dept-search-profileCarry that port safely to the Linux server with an SSH reverse tunnel (run on the Windows PC; Win10/11 ships OpenSSH):
ssh -R 9222:127.0.0.1:9222 linuxuser@gateway.serverLinux server:
.env→BROWSER_MODE=cdp,CDP_ENDPOINT=http://127.0.0.1:9222(local on the server, tunneled back to the Windows browser). Thennpm start.Login stays live (cookies refresh as the browser is used); no profile copy; the unauthenticated CDP port is never on the network. Downside: Windows PC off → searches fail until it’s back (use Mode B if that’s unacceptable).
Mode B — BROWSER_MODE=storagestate: snapshot, Linux self-sufficient
Windows PC:
npm run login(headed), log in, press Enter → writesauth.json(OS-agnostic JSON of cookies + localStorage).Copy
auth.jsonto the Linux server, setBROWSER_MODE=storagestate,STORAGE_STATE_FILE=./auth.json, runnpm start. Linux runs its own headless browser loading the snapshot — no tunnel, survives the Windows PC being off.Trade-off: a frozen snapshot — re-export when the SSO cookie expires; carries only cookies + localStorage (not IndexedDB/client certs) — fine for most SSO.
Mode A — BROWSER_MODE=persistent: Windows PC runs everything
If a spare Windows PC can be the always-on service host:
npm run loginthere (seeds the profile), thennpm startwithBROWSER_MODE=persistent.Linux servers are pure clients pointing at
http://<windows-pc>:8787/mcp.Simplest of all — no tunnel, no snapshot ceremony.
Client onboarding is identical in every mode: clients point at the gateway’s MCP URL; the gateway talks to whichever browser mode is configured.
Bring-up runbook — Mode C (Linux gateway + Windows browser)
The confirmed setup: a Linux server runs the gateway; an always-on
Windows PC runs a real Chrome (logged in once) and an SSH reverse tunnel.
No browser is downloaded to the Linux server (playwright-core only).
Windows PC (once, then leave running) — see windows/README.md
windows\start-browser.ps1→ dedicated Chrome on127.0.0.1:9222, profileC:\dept-search-profile. Sign in with the shared account (SSO/2FA). Keep it open.$env:GATEWAY_SSH = "linuxuser@gateway.server"; windows\start-tunnel.ps1→ maintainsssh -R 9222:127.0.0.1:9222 gateway, auto-reconnects.Make both Scheduled Tasks (At startup / On logon, run whether logged on or not) so the PC is a self-healing browser appliance.
Linux gateway server (this machine)
cd dept-web-search-gateway
cp .env.example .env
# edit .env:
# BROWSER_MODE=cdp (default)
# CDP_ENDPOINT=http://127.0.0.1:9222 (the tunneled port, local on this server)
# HOST=0.0.0.0
# ALLOWED_HOSTS=search.internal,localhost # hostnames clients will use
# GATEWAY_TOKEN=... (optional; else rely on network ACL)
npm install # lean — playwright-core, no Chromium download
npm run build # typecheck
npm start # dev (tsx); or `npm run build && npm run start:prod`
curl http://127.0.0.1:8787/health # {"ok":true,...}Point clients at http://<this-server>:8787/mcp (see Client onboarding).
Sanity-check the tunnel
On the Linux server:
curl -s http://127.0.0.1:9222/json/version # Chrome's JSON → tunnel + Chrome are upEmpty / connection refused → the Windows Chrome or the reverse tunnel isn’t
running yet; web_search will fail until it is.
Setup (one-time)
cd dept-web-search-gateway
npm install # also runs `playwright install chromium`
cp .env.example .env # then edit .env (see knobs below)1) Seed the shared login (the crux)
Run once on a machine with a display (or under xvfb-run -a):
npm run login
# or, for an internal portal:
LOGIN_START_URL=https://wiki.internal npm run loginA real Chrome window opens. Sign in with the shared service account
(SSO / 2FA), confirm you’re logged in to the search engine / portal, then close
the window. The session is persisted to BROWSER_PROFILE_DIR (default ./.profile)
and reused by the headless gateway from now on.
Servers are headless? Do the
npm run loginstep on the Windows PC, then choose Mode B (copyauth.jsonto Linux) or Mode C (SSH-tunnel CDP to Linux) as described in “Headless Linux servers + a Windows PC for login” above. Renewal when the SSO session expires: Mode A/B → re-runnpm run login(and re-copyauth.jsonfor B); Mode C → just re-login in the Windows Chrome.
2) Run the gateway
npm start # dev (tsx)
# or production:
npm run build && npm run start:prodYou should see:
[server] MCP gateway on http://0.0.0.0:8787/mcp (engine=bing)
[server] profile=./.profileClient onboarding (give these to your colleagues)
Replace search.internal / 8787 with your gateway host/port. Everyone uses
the same URL.
Chatbox (≥1.14)
Settings → MCP → Add Server → choose Remote / URL:
URL:
http://search.internal:8787/mcp(if
GATEWAY_TOKENis set) add a headerAuthorization: Bearer <TOKEN>where the client supports it; otherwise protect with network ACL.
One-click deep link (put on your intranet page):
chatbox://mcp/install?server=<base64 of {"name":"websearch","url":"http://search.internal:8787/mcp"}>OpenCode — all three flavors
Add to opencode.json (project) or ~/.config/opencode/opencode.json (global):
{
"mcp": {
"websearch": {
"type": "remote",
"url": "http://search.internal:8787/mcp",
"enabled": true
}
}
}Local opencode: same snippet, host =
127.0.0.1or the gateway host.Server opencode: the process runs on the server → point at the gateway’s internal URL directly (the server must reach it over the internal network).
vscode-remote opencode: the process runs on the remote host → point at the gateway’s internal URL (reachable from that host). No tunneling needed because the gateway is on the internal network.
Verify:
opencode mcp list.
Claude Code
claude mcp add --transport http websearch http://search.internal:8787/mcp
# with a token:
claude mcp add --transport http --header "Authorization: Bearer <TOKEN>" \
websearch http://search.internal:8787/mcpCline / Cursor / others
If they support remote MCP, point at the same URL. If they only do stdio, run a tiny local shim that calls the HTTP gateway (a 20-line wrapper) — not included here, but trivial to add.
Tools exposed
Tool | Args | Returns |
|
| list of |
|
|
|
The agent in Chatbox/OpenCode/Claude Code will call web_search when it needs
fresh info, and read_webpage to read a specific page — no extra wiring.
Config knobs (.env)
Var | Default | Meaning |
|
| bind address. |
| — | comma list of hostnames clients use (enables Host-header validation). Set when binding 0.0.0.0 |
|
| listen port |
| — | if set, require |
|
|
|
|
| cdp mode: the attached browser’s CDP URL (usually a tunneled port) |
|
| storagestate mode: login snapshot exported on Windows, copied here |
|
| persistent mode: Chrome profile holding the shared login |
|
|
|
|
| concurrency cap (one Chrome, isolated tabs) |
|
| per-page hard timeout |
|
|
|
| — | custom URL with |
|
| results per query |
| — | optional public-search fallback (needs outbound internet), e.g. |
Adding a custom internal-portal extractor
extractBing in src/tools.ts is tuned for Bing’s DOM. For an internal portal,
add extractPortal(page, count) and select it on the engine name in
searchWithBrowser. The generic extractGeneric already returns anchor links +
nearby text as a passable fallback for unknown DOMs.
Security & ops notes
Bind & expose: prefer keeping the gateway on the internal network. If you bind
0.0.0.0, setALLOWED_HOSTSand use a firewall / network ACL, or setGATEWAY_TOKEN, or put it behind an SSO reverse proxy.Shared profile = shared identity: every search is attributed to the shared account. Fine for a department service account; review if the target audits per-user or has quota.
Session renewal: re-run
npm run loginwhen SSO expires. Consider a weekly cron that emails a reminder, or a health probe that detects a login wall (read_webpageon a known-login-required URL returns the login page text).Concurrency / scale: one Chrome with isolated tabs handles a small department. Grow to a browser pool (N persistent contexts) if it saturates — the
withPageseam is the only place to change.Headless Chrome on Linux:
--no-sandbox --disable-dev-shm-usageare already set (container-friendly).
Development & testing
Probes live in
scripts/and import from../dist/, so build first:npm run build.scripts/probe-search.mjs "<query>"— drives the shared browser directly (bypasses MCP); validates CDP attach + the Bing extractor.scripts/probe-mcp.mjs <url> "<query>"— connects to a running gateway over Streamable HTTP (the real client path), lists tools, callsweb_search. Start the gateway first:node --env-file=.env dist/server.js.
Dev mode (
npm start→ tsx): under npm 11,tsx's transitiveesbuildpostinstall is blocked byallow-scriptsby default. Approve it once (npm approve-scripts) or just use the compiled path everywhere:npm run build && node --env-file=.env dist/server.js.
Status
This is a reviewable PoC / skeleton — verified against the v2 MCP SDK API
(@modelcontextprotocol/server 2.x, createMcpHandler / createMcpExpressApp
/ requireBearerAuth / toNodeHandler) and Playwright’s persistent-context
API. Before production: pin exact dependency versions, add tests, and harden the
auth layer (JWT / introspection instead of a static token) if you expose it
beyond a trusted internal network.
Design context (Mode A/B/C topologies, the cross-OS cookie-encryption gotcha, SearXNG boundaries) is in the “Headless Linux servers + a Windows PC for login” section above.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/yangsheng6810/web-search-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server