MCP 插件的核心不是“做一个搜索页面”而是把搜索能力包装成 AI 客户端能调用的工具。客户端负责发起tools/list和tools/call插件负责告诉客户端自己有哪些工具并在被调用时返回结构化结果。一个实用的 Web 搜索 MCP 插件至少要解决四件事协议通信、工具定义、搜索/抓取实现、可观测性。只要这四块清楚后面换搜索引擎、加缓存、加代理、加阅读器都只是扩展。插件在本机运行MCP 客户端通过协议调用它它再访问搜索引擎和网页先定目标只做两个工具第一版不要贪多。建议只做两个工具web_search输入关键词返回搜索结果列表。每条结果包含标题、链接、摘要、排序。fetch_url输入网页链接返回可读文本。包含原始 URL、最终 URL、状态码、内容类型、正文和是否截断。为什么不只做一个搜索工具因为搜索结果的摘要通常不够。AI 找到候选链接后还需要打开网页读取正文。把搜索和读取拆开调用链更清楚失败也更好排查。项目骨架用 Node.js 实现最省事因为 Node 18 以后内置fetch、AbortController和 Web Streams。一个最小目录可以这样放web-search-mcp/ package.json mcp/ server.mjs run-node.sh examples/ lmstudio-mcp.json scripts/ test-mcp.mjspackage.json里声明 ESM 和启动脚本即可{ name: web-search-mcp, private: true, type: module, scripts: { start: node ./mcp/server.mjs --stdio, start:sse: node ./mcp/server.mjs --sse --host 127.0.0.1 --port 8765, test: node ./scripts/test-mcp.mjs }, engines: { node: 18 } }声明工具让客户端知道能调用什么MCP 客户端会先问服务端工具列表。服务端返回的是一个数组每个工具包含名称、描述和 JSON Schema 输入定义。这个 schema 很重要它会影响客户端是否敢调用、参数怎么填。const tools [ { name: web_search, description: Search the public web and return title, URL, snippet, and rank., inputSchema: { type: object, properties: { query: { type: string, description: Search query. }, max_results: { type: integer, minimum: 1, maximum: 10, default: 5 }, timeout_ms: { type: integer, minimum: 1000, maximum: 30000, default: 12000 } }, required: [query], additionalProperties: false } }, { name: fetch_url, description: Fetch a page and return readable plain text., inputSchema: { type: object, properties: { url: { type: string }, max_chars: { type: integer, minimum: 200, maximum: 20000, default: 6000 } }, required: [url], additionalProperties: false } } ];这里有两个经验参数范围要收紧默认值要合理。比如搜索结果最多 10 条网页正文最多 20000 字符这些限制可以防止一次调用返回过多内容把上下文撑爆。实现 stdioMCP 最常见的接入方式很多桌面客户端会用 stdio 启动 MCP 服务。stdio 模式下消息不是一行一个 JSON而是带Content-Length头的 JSON-RPC 帧。服务端需要从 stdin 读数据解析完整帧再往 stdout 写回响应。function readFramedMessage(buffer) { const headerEnd buffer.indexOf(\r\n\r\n); if (headerEnd -1) return null; const header buffer.subarray(0, headerEnd).toString(utf8); const match header.match(/^Content-Length:\s*(\d)\s*$/im); if (!match) throw new Error(Missing Content-Length header); const length Number.parseInt(match[1], 10); const bodyStart headerEnd 4; const bodyEnd bodyStart length; if (buffer.length bodyEnd) return null; return { message: JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString(utf8)), rest: buffer.subarray(bodyEnd) }; } function writeFramedMessage(message) { const body JSON.stringify(message); process.stdout.write( Content-Length: ${Buffer.byteLength(body, utf8)}\r\n\r\n${body} ); }注意stdio MCP 的 stdout 必须只写协议帧。调试日志不要用console.log随便打到 stdout否则客户端会把日志当协议内容解析连接很容易断。路由 JSON-RPC 方法最小服务端至少处理四类方法初始化、心跳、列工具、调用工具。async function route(method, params, context) { switch (method) { case initialize: return { protocolVersion: params.protocolVersion ?? 2024-11-05, capabilities: { tools: {} }, serverInfo: { name: web-search-mcp, version: 0.1.0 } }; case ping: return {}; case tools/list: return { tools }; case tools/call: return callTool(params, context); default: throw rpcError(-32601, Method not found: ${method}); } }工具返回值建议统一成文本内容里面放格式化 JSON。这样通用性好客户端也容易展示。function textToolResult(payload) { return { content: [ { type: text, text: JSON.stringify(payload, null, 2) } ] }; }实现 web_search先把流程拆稳搜索工具不要一上来就写复杂解析。先把主流程拆成固定步骤校验参数、选择搜索引擎、请求 HTML、解析结果、裁剪数量、返回 JSON。把搜索拆成流水线后每一步都能独立替换和测试async function webSearch(args) { const query requiredString(args.query, query).trim(); if (!query) throw rpcError(-32602, query must not be empty); const maxResults clampInt(args.max_results ?? 5, 1, 10, max_results); const timeoutMs clampInt(args.timeout_ms ?? 12000, 1000, 30000, timeout_ms); const engines resolveSearchEngines(args); const searchParams new URLSearchParams({ q: query }); const { provider, results } await searchWeb({ searchParams, timeoutMs, maxResults, engines }); return { query, provider, engines_tried: engines, result_count: results.length, results }; }这里的resolveSearchEngines可以先写死为[duckduckgo, bing]跑通后再加环境变量和单次调用覆盖。搜索引擎回退可用性比单点速度更重要公开搜索页面可能会超时、返回验证码、页面结构变化或者没有可解析结果。因此不要把一个引擎失败等同于整个工具失败。更稳的方式是按顺序尝试只有全部失败才报错。async function searchWeb({ searchParams, timeoutMs, maxResults, engines }) { const failures []; for (const engine of engines) { try { const { provider, html } await requestSearchHtml(engine, searchParams, timeoutMs); const results parseSearchResults(engine, html).slice(0, maxResults); if (results.length 0) { return { provider, results }; } failures.push(${provider}: no parseable results); } catch (error) { failures.push(${engine}: ${error.message}); } } throw rpcError(-32000, Search failed. ${failures.join(. )}); }工程上推荐同时支持三层配置默认回退顺序、环境变量全局覆盖、单次工具调用临时覆盖。这样用户既能设置常用偏好也能在一次搜索里指定特定引擎。请求 HTML超时和 User-Agent 必须有网络请求要给超时否则一次卡住就会拖住客户端。Node 里可以用AbortController。async function requestText(url, { method GET, timeoutMs 12000, headers {}, body } {}) { const controller new AbortController(); const timeout setTimeout(() controller.abort(), timeoutMs); try { const response await fetch(url, { method, body, redirect: follow, signal: controller.signal, headers: { user-agent: Mozilla/5.0 (compatible; WebSearchMCP/0.1), accept: text/html,application/xhtmlxml,text/plain;q0.8,*/*;q0.5, ...headers } }); const text await response.text(); if (!response.ok) { throw rpcError(-32000, HTTP ${response.status} while fetching ${response.url}); } return { text, finalUrl: response.url, status: response.status }; } finally { clearTimeout(timeout); } }生产代码里还应该限制最大读取字节数避免下载特别大的页面。Ayu Web Search 的做法是最多读取 1MB再转成文本。解析搜索结果统一输出格式不同搜索引擎 HTML 结构不同但插件输出要统一。建议结果结构固定为{ rank: 1, title: Result title, url: https://example.com/page, snippet: Short summary from search result }解析时至少做三件事标题去 HTML 标签URL 只允许 http/https重复 URL 去重。下面是简化版 Bing 解析思路function parseBingResults(html) { const results []; const blockPattern /li[^]class[^]*\bb_algo\b[^]*[\s\S]*?(?li[^]class[^]*\bb_algo\b|\/ol)/gi; const blocks html.match(blockPattern) ?? []; for (const block of blocks) { const titleMatch block.match(/h2[^]*\s*a[^]href([^])[^]*([\s\S]*?)\/a/i); if (!titleMatch) continue; const url decodeHtml(titleMatch[1]); if (!isHttpUrl(url)) continue; const snippetMatch block.match(/p[^]*([\s\S]*?)\/p/i); const title htmlToText(titleMatch[2]); const snippet snippetMatch ? htmlToText(snippetMatch[1]) : ; if (title !results.some((item) item.url url)) { results.push({ rank: results.length 1, title, url, snippet }); } } return results; }这类正则解析不是万能的但对轻量插件来说足够直接。更稳的方案是引入 HTML 解析库不过这会增加依赖。第一版可以先用 fixture 测试锁住基本页面结构。实现 fetch_url把网页变成可读文本搜索工具只能帮 AI 找链接真正有用的信息往往在网页正文里。fetch_url的核心是校验 URL、下载页面、按内容类型处理、清理 HTML、按字符数截断。async function fetchUrl(args) { const rawUrl requiredString(args.url, url).trim(); const url validateHttpUrl(rawUrl); const maxChars clampInt(args.max_chars ?? 6000, 200, 20000, max_chars); const { text, contentType, finalUrl, status } await requestTextWithMeta(url.href, { method: GET, timeoutMs: args.timeout_ms ?? 12000, maxBytes: 1_000_000 }); const plainText contentType.includes(html) ? htmlToText(text) : normalizeText(text); return { url: rawUrl, final_url: finalUrl, status, content_type: contentType || unknown, text: plainText.slice(0, maxChars), truncated: plainText.length maxChars }; }HTML 转文本可以先用轻量规则去掉 script、style、noscript把段落、标题、列表、换行转换成空格或换行再解码 HTML 实体。function htmlToText(html) { return normalizeText( decodeHtml( html .replace(/script[\s\S]*?\/script/gi, ) .replace(/style[\s\S]*?\/style/gi, ) .replace(/noscript[\s\S]*?\/noscript/gi, ) .replace(/br\s*\/?/gi, \n) .replace(/\/(p|div|li|h[1-6]|tr|section|article)/gi, \n) .replace(/[^]/g, ) ) ); }错误码要像 API 一样认真设计MCP 调用本质上是 JSON-RPC。参数错误、方法不存在、网络失败不应该都抛成普通异常。建议至少区分-32601方法不存在。-32602参数不合法比如 query 为空、URL 不是 http/https。-32000执行失败比如搜索引擎超时、HTTP 返回错误、所有引擎都不可用。错误清楚客户端和用户才知道该改参数、换网络还是换搜索引擎。日志不要污染 stdout工具调用日志建议写成 JSONL每次调用一行。字段不用太多但要能回答几个问题哪个工具、什么输入、哪个 provider、耗时多少、成功还是失败。{ ts: 2026-06-13T10:00:00.000Z, event: tool_call, status: ok, transport: stdio, tool: web_search, duration_ms: 421, input: { query: OpenAI, max_results: 5 }, output: { provider: bing-html, result_count: 5, top_url: https://example.com } }如果是 stdio 模式默认可以把日志写到 stderr如果用户配置了LOG_FILE或类似环境变量就追加到文件。不要把日志写到 stdout。SSE给支持远程连接的客户端用stdio 足够常见但有些客户端更适合通过 SSE 连接。SSE 版本一般包含两个接口客户端 GET/sse建立事件流服务端返回一个带 sessionId 的消息入口客户端 POST JSON-RPC 到/messages?sessionId...服务端再把响应推回 SSE 流。第一版可以先不做 SSE。等 stdio 跑稳后再把同一个handleJsonRpcMessage复用到 HTTP 服务里。协议处理和业务逻辑不要写两份。测试用 fixture不要全靠真实网络Web 搜索插件最容易犯的错是只用真实网络手测。真实搜索会受地区、验证码、频率和页面变化影响不适合作为基础测试。建议准备几份本地 HTML fixture分别模拟搜索结果页和普通网页。烟测至少覆盖这些路径服务能响应initialize。服务能通过tools/list返回web_search和fetch_url。web_search能从 fixture 解析出标题、URL、摘要。fetch_url能把 mock HTML 转成正文。真实联网测试可以单独放一个命令例如test:live。它用来验证当前网络环境不要作为唯一质量门槛。客户端配置示例stdio 客户端通常需要配置 command 和 args。以本地路径为例{ mcpServers: { web-search-mcp: { command: node, args: [ /absolute/path/to/web-search-mcp/mcp/server.mjs, --stdio ], env: { SEARCH_ENGINES: duckduckgo,bing, LOG_FILE: /tmp/web-search-mcp.jsonl } } } }如果你实现了 SSE可以给支持 SSE 的客户端提供 URL{ mcpServers: { web-search-mcp: { url: http://127.0.0.1:8765/sse } } }可以继续增强的地方第一版跑通后可以按需求继续增强。比如给搜索结果加缓存减少重复请求给网页正文提取换成更强的 Readability 类库给企业环境加代理配置给搜索引擎解析加更多 fixture给日志增加 request id方便把一次搜索和后续 fetch 串起来。但这些都应该建立在稳定的最小版本之上。一个 MCP 插件最怕的是功能很多协议不稳日志又看不懂。先把工具声明、调用、错误、测试做扎实扩展会轻松很多。最后的实现顺序真正动手时可以按这个顺序做先写 stdio JSON-RPC 收发再让tools/list返回两个工具接着用 fixture 跑通web_search然后实现fetch_url最后加回退、日志、SSE 和真实联网测试。这样做的好处是每一步都有明确的可验证结果。等客户端能稳定调用搜索和读取网页一个 Web 搜索 MCP 插件就已经具备实际使用价值了。