当前位置: 首页 > news >正文

从零实现一个 Web 搜索 MCP 插件

MCP 插件的核心不是“做一个搜索页面”,而是把搜索能力包装成 AI 客户端能调用的工具。客户端负责发起tools/listtools/call,插件负责告诉客户端自己有哪些工具,并在被调用时返回结构化结果。

一个实用的 Web 搜索 MCP 插件,至少要解决四件事:协议通信、工具定义、搜索/抓取实现、可观测性。只要这四块清楚,后面换搜索引擎、加缓存、加代理、加阅读器,都只是扩展。

插件在本机运行,MCP 客户端通过协议调用它,它再访问搜索引擎和网页

先定目标:只做两个工具

第一版不要贪多。建议只做两个工具:

web_search:输入关键词,返回搜索结果列表。每条结果包含标题、链接、摘要、排序。

fetch_url:输入网页链接,返回可读文本。包含原始 URL、最终 URL、状态码、内容类型、正文和是否截断。

为什么不只做一个搜索工具?因为搜索结果的摘要通常不够。AI 找到候选链接后,还需要打开网页读取正文。把搜索和读取拆开,调用链更清楚,失败也更好排查。

项目骨架

用 Node.js 实现最省事,因为 Node 18 以后内置fetchAbortController和 Web Streams。一个最小目录可以这样放:

web-search-mcp/ package.json mcp/ server.mjs run-node.sh examples/ lmstudio-mcp.json scripts/ test-mcp.mjs

package.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 字符,这些限制可以防止一次调用返回过多内容,把上下文撑爆。

实现 stdio:MCP 最常见的接入方式

很多桌面客户端会用 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/xhtml+xml,text/plain;q=0.8,*/*;q=0.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_searchfetch_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 插件就已经具备实际使用价值了。

http://www.zskr.cn/news/1537141.html

相关文章:

  • 2026最新救命贴:Turnitin英文初稿降ai率实操,这套降aigc保姆级教程千万别错过 - 殷念写论文
  • 肖有米开发:推三返一模式系统开发推三返一现成小程序开发
  • 如何快速掌握ViGEmBus虚拟手柄驱动:Windows游戏控制器兼容性终极解决方案
  • 2026成都卖劳力士首选!5 家实体店深度测评禹竞名奢汇 - 禹竞
  • 2026热门潜水表回收行情解析,南京劳力士无附件手表回收实测 - 奢侈品回收评测
  • BepInEx:Unity游戏插件框架的技术革新与多运行时生态构建
  • 【科普】城阳区新房收房,防水验收重点查这几个位置 - 青岛防水品牌推荐
  • 【Spring Boot + MyBatis|第9篇】使用 AOP 实现接口操作日志记录
  • manjaro安装电脑版微信
  • 2026武汉黄金回收实测:这家从检测到收款只用一首歌时间 - 奢侈品回收测评
  • 临沂北城新区专业管道疏通 2026 真实评测最新综合排行榜 - 居顺联家政疏通
  • Java 基础第四篇 | 循环结构:while、do-while、for
  • 卖表别被坑!2026 杭州名表回收套路盘点,浪琴名匠、帝舵碧湾怎么卖价最高 - 奢侈品回收评测
  • Python-100-Days实战:从零构建企业级RESTful API架构深度指南
  • 2026 年 6 月长沙艺体特色高中测评,升学避坑指南 - 讲清楚了
  • 客户口碑好的GEO优化公司怎么选?2026避坑指南|干货 - 品牌测评鉴赏家
  • 保研边缘人逆袭指南:从‘末流211’到东南软院,我的GPA、竞赛与面试全复盘
  • 家中闲置包包配件齐全怎么溢价?2026深圳收的顶官方顶估价标准公开 - 奢侈品回收测评
  • 2026济南名表回收排名出炉:添价收荣登榜首,七家品牌实力盘点 - 薛定谔的梨花猫
  • 西门子博图ModbusRTU轮询FB
  • HTML打包EXE离线一机一码新增试用功能(附2026最新版下载地址)
  • 持证鉴定 + 资金兜底,2026 厦门黄金回收标杆品牌权威排行榜 - 奢侈品回收评测
  • 20260616第三周
  • 在鸿蒙PC上使用pkgsrc进行包管理
  • 回收店不会说的秘密:合肥首饰保值、贬值的核心原因 - 奢侈品回收评测
  • 终极3DS游戏格式转换指南:3dsconv让你的游戏管理更高效
  • ARINC429数据收发老出错?可能是你的HI-3593 SPI配置没搞对(调试避坑实录)
  • 2026年深圳专利申请机构推荐全景榜:从产业分层视角看五家代表性服务方的选型逻辑 - 速递信息
  • 2026年北京黄金回收白名单:本地人亲测、无套路的六家正规回收门店测评 - 名奢变现站
  • 告别‘命令未找到’:在Ubuntu 20.04/22.04上快速搞定ARM交叉编译环境(含gcc-arm-linux-gnueabihf配置)