How to Build a Scholarship Research MCP Server with Node.js, Express, and MongoDB
TL;DR · AI 摘要
本文详解如何用Node.js、Express和MongoDB构建符合MCP协议的奖学金研究服务器,实现AI与数据库的协同工作。
核心要点
- MCP协议通过JSON-RPC实现AI与工具的数据交互,支持多客户端统一接入
- 项目包含9个MCP工具实现搜索、匹配、截止日期跟踪等功能
- MongoDB存储奖学金目录、书签和研究笔记,支持流式HTTP服务
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- MCP奖学金服务器架构
- 协议层
- JSON-RPC数据传输
- 工具注册接口
- 数据层
- MongoDB存储
- 奖学金目录
- 书签集合
- 应用层
- Express服务
- MCP工具集
金句 / Highlights
值得收藏与分享的关键句。
MCP协议使AI应用能通过单一接口对接多个工具,避免重复开发
MongoDB存储奖学金数据时需包含金额、截止日期和资格规则字段
Express服务通过流式HTTP接口暴露奖学金目录和研究提示模板
如何使用 Node.js、Express 和 MongoDB 构建奖学金研究 MCP 服务器
2026年9月4日
/
#mcp
Chinedu Otutu
奖学金申请是一项研究工作,而非简单的搜索框操作。你需要根据专业领域、GPA、国籍和截止日期筛选奖项,保存候选名单,撰写关于论文和推荐人的笔记。一周后你可能需要重新审视某个项目,却难以回忆起当初保存它的理由。
AI 助手可以协助这一流程,但前提是它能够查询真实目录并保存你已做出的决策。聊天记录无法替代数据库,而错误的截止日期(如由模型幻觉生成的)甚至比没有截止日期更糟糕。
模型上下文协议(Model Context Protocol,简称 MCP)是为 AI 应用提供此类访问权限的标准方式。在本教程中,你将使用 Node.js、Express 和 MongoDB 构建一个奖学金研究 MCP 服务器。
完成之后,Cursor、Claude Desktop 或其他任何 MCP 宿主都可以搜索奖项、将奖项匹配到学生档案、保存候选名单并存储研究笔记。模型保持推理层功能,而你的服务器则拥有数据控制权。
你将构建以下内容:
- 一个包含奖学金信息的 MongoDB 目录,以及保存列表和笔记的集合
- 九个 MCP 工具,用于搜索、匹配、截止日期管理、比较和研究跟踪
- 使客户端无需调用工具即可读取目录的资源接口
- 将学生档案转化为研究计划的提示模板
- 通过可流式传输的 HTTP 协议提供 MCP 的 Express 应用
本项目中的示例目录是一个教学数据集。金额、日期和资格规则已简化。申请前请务必在官方申请页面确认详细信息。
你需要的准备
你应该熟悉 JavaScript 和基础的 Express 路由。你不需要 MCP 的先前经验。
安装以下内容:
- Node.js 20 或更高版本
- 本地运行的 MongoDB 或免费的 MongoDB Atlas 集群
- 如果想尝试最后一部分,安装一个 MCP 客户端(Cursor 和 Claude Desktop 均适用)
Docker 足以运行 MongoDB:
docker run -d --name mongo -p 27017:27017 mongo:7目录
- 你需要的准备
- 什么是模型上下文协议?
- 为什么需要奖学金研究服务器?
- 架构如何协同工作
- 如何设置项目
- 如何连接 MongoDB
- 如何建模奖学金数据
- 如何编写奖学金服务
- 如何注册 MCP 工具
- 如何暴露资源和提示
- 如何通过 Express 提供 MCP
- 如何初始化目录
- 如何测试服务器
- 如何连接 Cursor 和 Claude Desktop
- 研究会话如何运行
- 匹配逻辑如何工作
- 接下来你可以构建什么
- 结论
什么是模型上下文协议?
MCP 是一种开放协议,允许 AI 应用通过共享契约与外部工具和数据源通信。Anthropic 于 2024 年推出该协议,现在作为开放标准进行维护。
Anthropic 将 MCP 描述为“AI 应用的 USB-C 接口”:一个连接器,多个宿主。(来源:介绍模型上下文协议)你无需分别为 Cursor、Claude Desktop 和自定义代理编写三次集成,只需实现一次协议即可。
官方架构概述将 MCP 分为两层:
- 数据层使用 JSON-RPC 2.0。客户端和服务器交换如
tools/list和tools/call的请求。 - 传输层负责传输这些消息。本地服务器通常使用 stdio。远程或长期运行的服务器使用可流式传输的 HTTP。
本教程使用 Streamable HTTP,因为 Express 本身已经是一个 HTTP 服务器,并且你希望让目录对机器上的任何客户端都可用。
主机、客户端和服务器
每个 MCP 配置中都会出现三个角色:
- 主机是 AI 应用。Cursor 和 Claude Desktop 都是主机。
- 客户端位于主机内部。主机为每个连接的服务器创建一个客户端。
- 服务器是你的程序。它会宣传工具、资源和提示,然后处理调用。
你的奖学金应用就是服务器。你永远不会直接与模型 SDK 通信。主机来完成这个任务。
工具、资源和提示
MCP 服务器暴露了三种原始元素。你将全部使用到它们。
工具是动作。模型决定何时调用它们,就像在常规的工具调用 API 中调用函数一样。搜索、保存和比较属于这一类。
资源是主机可以读取并作为上下文附加的数据。目录 URI 和每个奖学金的 URI 属于这一类。模型无需“采取行动”即可查看它们。
提示是命名的模板。人们通常通过斜杠命令或菜单调用它们。一个“研究计划”提示属于这一类,因为它是你有意启动的工作流。
这种划分很重要。如果你将所有内容都放在工具中,模型就必须猜测何时查找信息。资源和提示为宿主提供了更精细的控制选项。
为什么需要奖学金研究服务器?
天气 MCP 演示只需要一次 API 调用。奖学金研究更接近真实产品:
- 目录必须可查询。关键字搜索、GPA 过滤和截止日期窗口都存在于数据库中。
- 工作流必须持久化。保存的短名单和研究笔记应能跨新聊天会话保留。
- 资格是逻辑而非文字。最低 GPA 是一个数字。仅限第一代是布尔值。将这些检查写入代码,防止模型虚构匹配。
- 输出必须可检查。学生应能打开官方 URL 并验证所有声明。
MongoDB 与之匹配良好。每个奖学金是一个文档,包含学习领域、国籍和要求的嵌套数组。保存的项目和笔记是单独的集合,带有回指目录的引用。
你可以使用公共 API 而不是存储文档。这是一个很好的后续步骤。从自己的目录开始可以让教程保持自包含,并使 MCP 合约显而易见。
架构如何协同工作
最终项目结构如下:
MCP 主机 (Cursor 或 Claude Desktop)
|
| Streamable HTTP POST /mcp
v
Express 应用 (createMcpExpressApp)
|
| createMcpHandler 工厂
v
McpServer 工具 / 资源 / 提示
|
v
奖学金服务
|
v
MongoDB 奖学金、savedScholarships、researchnotes在编写代码之前,有几个设计选择值得强调。
MCP 处理器是无状态的。SDK 每个 HTTP 请求只运行一次你的服务器工厂。这是 Streamable HTTP 推荐的 v2 模式。不要在 McpServer 实例上保存工具状态。将其保存在 MongoDB 中。
数据库连接是进程范围的。每个请求都连接会很慢且没有意义。你只需在启动时连接一次,并从工具中闭包该连接池。
HTTP 接口故意保持小巧。/health 是为你准备的。/mcp 是协议接口。除非你之后需要,否则不需要在相同数据前放置 REST API。
如何设置项目
创建一个文件夹并初始化 Node.js 项目。必须使用 ESM,因为 MCP SDK 首选 ESM。
mkdir scholarship-research-mcp-server
cd scholarship-research-mcp-server
npm init -y打开 package.json 并设置 "type": "module"。然后安装 SDK、Express、Mongoose、Zod 和 dotenv:
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express mongoose dotenv zodv2 版本将 SDK 拆分为多个包:
- @modelcontextprotocol/server 包含 McpServer 类和 createMcpHandler
- @modelcontextprotocol/express 提供 createMcpExpressApp,包含 DNS 重绑定防护
- @modelcontextprotocol/node 将 Web 标准处理器适配到 Node 的 req/res
创建 .env 文件:
MONGODB_URI=mongodb://127.0.0.1:27017/scholarship_research
PORT=3000
HOST=127.0.0.1添加 .gitignore 文件排除 node_modules 和 .env:
node_modules/
.env你的源码结构可以保持简洁:
src/
index.js
config.js
db.js
models/
services/
mcp/
utils/
data/
scholarships.json
scripts/
seed.js
smoke-test.jssrc/config.js 使用默认值读取环境变量:
export const config = {
mongodbUri: process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/scholarship_research",
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? "127.0.0.1",
};将配置集中在一个文件中。工具不应直接读取 process.env。
如何连接 MongoDB
Mongoose 9 可以干净地与 ESM 一起工作。一个简短的 src/db.js 就足够了:
import mongoose from "mongoose";
import { config } from "./config.js";
export async function connectDatabase() {
mongoose.set("strictQuery", true);
await mongoose.connect(config.mongodbUri);
return mongoose.connection;
}在 src/index.js 中调用此函数,在 app.listen 之前。如果连接失败,进程应退出。运行中的 Express 服务器如果数据库连接失败,比启动失败更难调试。
如何建模奖学金数据
你需要三个集合。
奖学金
这是目录。存储匹配引擎实际使用的字段,而不是一段 markdown 文本。
import mongoose from "mongoose";
const scholarshipSchema = new mongoose.Schema(
{
provider: { type: String, required: true, trim: true },
description: { type: String, required: true },
amountMin: { type: Number, default: 0 },
amountMax: { type: Number, default: 0 },
currency: { type: String, default: "USD" },
deadline: { type: Date, default: null },
rolling: { type: Boolean, default: false },
educationLevels: { type: [String], default: ["undergraduate"] },
fieldsOfStudy: { type: [String], default: ["any"] },
gpaMinimum: { type: Number, default: null },
citizenship: { type: [String], default: ["any"] },
countries: { type: [String], default: ["any"] },
firstGenerationOnly: { type: Boolean, default: false },
womenOnly: { type: Boolean, default: false },
numberOfAwards: { type: Number, default: 1 },
renewable: { type: Boolean, default: false },
applicationUrl: { type: String, required: true },
applicationRequirements: { type: [String], default: [] },
},
{ timestamps: true },
);
scholarshipSchema.index({
provider: "text",
description: "text",
fieldsOfStudy: "text",
});
scholarshipSchema.index({ deadline: 1 });
scholarshipSchema.index({ amountMax: -1 });
export const Scholarship = mongoose.model("Scholarship", scholarshipSchema);一些字段选择正在实际工作:
- fieldsOfStudy: ["any"] 表示该奖学金面向所有领域开放。匹配器会将 any 视为通配符。
- deadline: null 加上 rolling: true 表示该项目全年接受申请。
- applicationUrl 是必填字段。每个工具的结果都应指向可人工验证的来源。
- gpaMinimum: null 表示提供方未公布最低分数线。这与 0 不同。
保存的奖学金
研究列表是个人与目录条目之间的关联。
const savedScholarshipSchema = new mongoose.Schema(
{
researcherId: { type: String, required: true, default: "default" },
scholarship: {
type: mongoose.Schema.Types.ObjectId,
ref: "Scholarship",
required: true,
},
status: {
type: String,
enum: ["saved", "applying", "submitted", "won", "rejected"],
default: "saved",
},
},
{ timestamps: true },
);
savedScholarshipSchema.index({ researcherId: 1, scholarship: 1 }, { unique: true });唯一索引使保存奖学金的操作具有幂等性。重复保存相同奖项时会更新状态而非创建重复条目。
researcherId 是一个普通字符串。对于教程来说,这样已经足够。在生产环境中应从认证令牌中获取该值。
研究笔记
笔记作为独立集合存储,这样一份奖学金可以对应多个笔记。
const researchNoteSchema = new mongoose.Schema(
{
researcherId: { type: String, required: true, default: "default" },
scholarship: {
type: mongoose.Schema.Types.ObjectId,
ref: "Scholarship",
required: true,
},
body: { type: String, required: true, trim: true },
},
{ timestamps: true },
);现在你已拥有目录、备选清单和笔记本。这就是 MCP 工具将要封装的完整产品界面。
如何编写奖学金服务
将 MongoDB 查询从 MCP 层解耦。工具应调用服务,获取普通对象,再进行文本格式化。这使得相同功能可被种子脚本、烟雾测试或未来的 REST 接口复用。
搜索
搜索功能是过滤器构建器。每个可选参数会添加一个条件。开放或滚动录取的奖项会保留在结果集,已关闭的截止日期会被过滤。
const OPEN_DEADLINE_FILTER = {
$or: [{ rolling: true }, { deadline: null }, { deadline: { $gte: new Date() } }],
};
export async function searchScholarships(filters) {
const query = { ...OPEN_DEADLINE_FILTER };
const and = [query];
if (filters.keyword) {
and.push({
$or: [
{ title: { $regex: escapeRegex(filters.keyword), $options: "i" } },
{ provider: { $regex: escapeRegex(filters.keyword), $options: "i" } },
{ description: { $regex: escapeRegex(filters.keyword), $options: "i" } },
{ fieldsOfStudy: { $regex: escapeRegex(filters.keyword), $options: "i" } },
],
});
}
if (filters.fieldOfStudy) {
and.push({
$or: [
{ fieldsOfStudy: { $regex: `^any$`, $options: "i" } },
{ fieldsOfStudy: { $regex: escapeRegex(filters.fieldOfStudy), $options: "i" } },
],
});
}
// educationLevel, citizenship, country, minAmount, gpa, flags...
const results = await Scholarship.find({ $and: and })
.sort({ deadline: 1, amountMax: -1 })
.limit(100)
.lean();
return rankByFieldMatch(results, filters.fieldOfStudy).slice(0, filters.limit ?? 10);
}有两个细节容易被忽略但值得保留。
在将用户输入插入 $regex 之前务必进行转义。关键字 ( 不应变成无效的正则表达式。
当学生搜索计算机科学相关领域时,所有开放奖项(任何类型)均可参与,但具体的计算机科学奖学金应优先显示。查询结果将根据内存中的排名进行排序。MongoDB 已经完成了资格过滤,你只需调整显示顺序。
匹配
匹配与搜索不同。搜索是“查找类似这样的文档”,而匹配是“这里有一个学生,为所有开放奖项打分”。
服务会加载开放奖学金,然后应用硬性过滤条件和评分规则:
| 信号 | 效果 | |------|------| | 教育层次不匹配 | 跳过 | | GPA 低于最低要求 | 跳过 | | 公民身份不匹配 | 跳过 | | 仅限第一代学生但学生不符合 | 跳过 | | 仅限女性但学生不符合 | 跳过 | | 教育层次匹配 | +20 | | GPA 符合要求 | +15 | | 公民身份符合 | +10 | | 专业领域匹配 | +30 | | 优选国家匹配 | +10 | | 金额满足学生最低要求 | +10 | | 仅限第一代或女性匹配 | +10 |
硬性过滤条件可以防止虚假希望,软性评分规则对剩余结果进行排序。每个结果还会返回一个 reasons 数组,使模型能够解释匹配原因,而不是编造理由。
最后这一点正是将匹配逻辑放在服务器端的全部原因。如果你只返回原始文档,模型有时会“好心”包含学生无法申请的奖项。返回评分和原因可以让解释始终基于代码逻辑。
保存、备注、截止日期、比较
剩余功能实现较为简单:
- saveScholarship 通过 (researcherId, scholarshipId) 进行更新或插入操作
- addResearchNote 在确认奖学金存在后插入备注
- getUpcomingDeadlines 查询当前时间到 N 天后的截止日期
- compareScholarships 加载两个或三个文档并返回每个文档的相同字段
在查询前使用 mongoose.Types.ObjectId.isValid 验证 MongoDB ID。LLM 有时会在需要 ID 的地方传递标题。要明确失败处理,不要将 CastError 抛入 MCP 传输层。
如何注册 MCP 工具
创建 src/mcp/server.js 作为工厂类。HTTP 处理器会在每次请求时调用它。
import { McpServer } from "@modelcontextprotocol/server";
import { registerPrompts } from "./prompts.js";
import { registerResources } from "./resources.js";
import { registerTools } from "./tools.js";
export function createScholarshipServer() {
const server = new McpServer({
name: "scholarship-research",
version: "1.0.0",
});
registerTools(server);
registerResources(server);
registerPrompts(server);
return server;
}保持这个工厂类轻量。不要包含数据库连接、文件读取或模块作用域级别的缓存。HTTP 服务指南对此有明确说明:在启动时一次性创建连接池,并通过闭包进行管理。
完整工具实现
registerTool 需要名称、配置对象和处理函数。inputSchema 是一个 Zod 对象。SDK 会将该模式转换为 tools/list 使用的 JSON Schema,在你的处理函数运行前验证参数,并在使用 TypeScript 时推断类型。
import * as z from "zod/v4";server.registerTool( "search_scholarships", {
description: "通过关键词、研究领域、教育阶段、国籍、国家、GPA 和奖金金额在奖学金目录中进行搜索。", inputSchema: z.object({ keyword: z.string().min(1).optional().describe("在标题、提供者、描述和领域中进行自由文本搜索"), fieldOfStudy: z.string().optional().describe("例如计算机科学、公共卫生或工程"), educationLevel: z.enum(["undergraduate", "graduate", "doctoral"]).optional(), citizenship: z.string().optional(), country: z.string().optional(), minAmount: z.number().nonnegative().optional(), gpa: z.number().min(0).max(4).optional(), firstGeneration: z.boolean().optional(), womenOnly: z.boolean().optional(), limit: z.number().int().min(1).max(25).optional(), }), annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { const results = await searchScholarships(args); return toolText(formatScholarshipList(results)); }, );
将描述视为模型唯一能获取的文档。Zod 字段上的 .describe() 在转换为 JSON Schema 时会保留。这是主机告诉模型 fieldOfStudy 含义的方式。
title 是给人看的标签,description 是模型面对的契约,它们不是相同的字符串。
### 注解
注解不会改变 SDK 运行工具的方式。主机使用它们来决定需要多谨慎。
- readOnlyHint: 搜索、获取、匹配、列表、比较和截止日期设为 true
- readOnlyHint: 保存和添加备注设为 false
- idempotentHint: 保存设为 true,因为有唯一索引
- openWorldHint: 设为 false,因为这个服务器连接的是你的数据库,而不是开放网络
主机可以自动批准只读搜索,但在写入操作前询问用户。这在配置中值得增加五个额外的键。
### 返回结构
每个工具都返回 MCP 内容块:
export function toolText(text, isError = false) { return { content: [{ type: "text", text }], isError, }; }
对于领域错误(如“未找到奖学金”)返回 isError: true。仅对意外失败抛出异常。规范对此类错误的处理方式不同。来自 Zod 的验证错误永远不会到达你的处理程序,SDK 已将其转换为 isError 结果。
为快速浏览聊天记录的人格式化列表。在每一行都包含 MongoDB 的 id。后续工具需要这个 id,而模型无法生成有效的 ObjectId。
### 完整工具集
服务器注册了九个工具:
工具
功能描述
search_scholarships
筛选目录
get_scholarship
返回完整记录
match_scholarships
根据学生档案对奖项进行评分
save_scholarship
收藏奖项
list_saved_scholarships
显示备选列表
add_research_note
添加备注
list_research_notes
读取备注
get_upcoming_deadlines
截止日期窗口
compare_scholarships
并排比较两个或三个 id
这足以完成研究循环:查找、检查、匹配、保存、标注和比较。
抵制添加 delete_everything 工具的冲动。破坏性工具需要额外确认,不属于此工作流程。
## 如何暴露资源和提示
工具不是主机获取上下文的唯一方式。
### 资源
静态资源是一个固定的 URI。目录符合这个定义:
server.registerResource( "scholarship-catalog", "scholarship://catalog", {
description: "当前存储在 MongoDB 中的开放奖学金", mimeType: "application/json", }, async (uri) => { const catalog = await listCatalog(); return { contents: [ { uri: uri.href, mimeType: "application/json", text: JSON.stringify(catalog, null, 2), }, ], }; }, );
资源模板可以覆盖一组 URI。使用 scholarship://item/{id} 而不是 scholarship://{id}。如果模式是 scholarship://{id},URI scholarship://catalog 就会变得模棱两可。
import { ResourceTemplate } from "@modelcontextprotocol/server";
server.registerResource( "scholarship-record", new ResourceTemplate("scholarship://item/{id}", { list: async () => { const catalog = await listCatalog(20); return { resources: catalog.map((scholarship) => ({ uri: scholarship://item/${scholarship._id}, name: scholarship.title, mimeType: "text/plain", })), }; }, }), {
description: "某项奖学金的完整详情", mimeType: "text/plain", }, async (uri, { id }) => { const scholarship = await getScholarshipById(id); return { contents: [ { uri: uri.href, mimeType: "text/plain", text: scholarship ? formatScholarship(scholarship) : 未找到 id 为 ${id} 的奖学金。, }, ], }; }, );
模板需要定义 list 方法。如果无法列举实例,请传入 undefined。在此场景下可以列举,因此主机可以显示选择器。
### 提示
提示是您希望用户启动的工作流程。研究计划提示本身不会查询 MongoDB。它告诉模型使用工具,然后构建答案。
server.registerPrompt( "research-plan", {
description: "根据学生档案生成每周研究和申请计划", argsSchema: z.object({ fieldOfStudy: z.string(), educationLevel: z.string(), citizenship: z.string(), gpa: z.string(), weeks: z.string().optional(), }), }, ({ fieldOfStudy, educationLevel, citizenship, gpa, weeks }) => ({ messages: [ { role: "user", content: { type: "text", text: `为该学生创建一个 ${weeks || "6"} 周的奖学金研究计划。
专业领域: ${fieldOfStudy} 教育程度: ${educationLevel} 国籍: ${citizenship} GPA: ${gpa}
首先使用奖学金研究工具查找真实奖项。然后生成候选名单、每周计划和风险分析。从工具结果中引用实际奖学金名称和日期。`, }, }, ], }), );
注意指令 "使用奖学金研究工具"。提示不能替代工具。它是一个脚本,能提高工具使用概率并使输出格式更一致。
第二个提示 application-checklist 会接收奖学金 id,并要求生成文档列表和倒序日历。这就是 MCP 提示擅长处理的重复性工作类型。
在许多主机中,提示参数即使值为数字也都是字符串类型。将 gpa 定义为字符串可避免在斜杠命令表单中出现"期望数字却收到字符串"的错误。
## 如何通过 Express 提供 MCP 服务
这是曾经用于处理会话的页面代码部分。在 SDK v2 中,它被替换为一个工厂加一个路由。
import "dotenv/config"; import { createMcpExpressApp } from "@modelcontextprotocol/express"; import { toNodeHandler } from "@modelcontextprotocol/node"; import { createMcpHandler } from "@modelcontextprotocol/server"; import { config } from "./config.js"; import { connectDatabase } from "./db.js"; import { createScholarshipServer } from "./mcp/server.js";
const mcpHandler = createMcpHandler(() => createScholarshipServer()); const nodeHandler = toNodeHandler(mcpHandler);
const app = createMcpExpressApp({ host: config.host, allowedHosts: ["127.0.0.1", "localhost"], });
app.get("/health", (_req, res) => { res.json({ status: "ok", service: "scholarship-research-mcp", transport: "streamable-http", }); });
app.all("/mcp", (req, res) => { void nodeHandler(req, res, req.body); });
async function start() { await connectDatabase(); app.listen(config.port, config.host, () => { console.log(Scholarship research MCP server listening on http://${config.host}:${config.port}/mcp); }); }
start();
逐个解释每个辅助函数的作用。
createMcpHandler 接受一个返回新 McpServer 的函数。它暴露了一个符合 Web 标准的 fetch 接口。这与你从 Cloudflare Worker 导出的处理器是相同的。
toNodeHandler 将这个 fetch 接口适配为 Express 的 (req, res) 形式。你需要将 req.body 作为第三个参数传递,因为 createMcpExpressApp 已经运行了 express.json()。如果你省略了 body,适配器会尝试读取 Express 已经消费的流。
createMcpExpressApp 是 express() 的增强版,增加了两个功能:JSON 解析和 Host/Origin 检查。这些检查是为了解决 DNS 重绑定问题。恶意页面可以将自身的域名指向 127.0.0.1,如果没有 Host 检查,你的浏览器会将本地 MCP 服务器视为同源。因此默认绑定地址是 127.0.0.1。Express 服务指南中对此有更详细的说明。
app.all("/mcp", ...) 是有意为之的设计。Streamable HTTP 使用 POST 处理 JSON-RPC,使用 GET 处理 SSE 流。只注册 POST 会破坏部分客户端的兼容性。
在接收到 SIGINT 信号时关闭处理器:
process.on("SIGINT", async () => { await mcpHandler.close(); process.exit(0); });
close() 会等待正在进行的请求完成。之后才能安全退出。
添加 npm 脚本:
{ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", "seed": "node scripts/seed.js", "smoke": "node scripts/smoke-test.js" } }
node --watch 对本地开发已经足够。这个项目不需要使用 nodemon。
## 如何填充目录
当 MCP 工具面对空数据库时,会返回 "no scholarships matched"。这是正确的,但会给用户留下不好的第一印象。
在 data/scholarships.json 中放入 20 到 30 条真实的数据记录。混合以下类型:
- 本科和研究生奖项
- STEM 领域和面向所有领域的奖项
- 针对特定国家的项目和面向全球的项目
- 滚动截止日期和固定截止日期
- 首代学生专属和仅限女性的标记
种子脚本应该替换目录而不是追加数据:
import "dotenv/config"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { connectDatabase } from "../src/db.js"; import { Scholarship } from "../src/models/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function seed() { await connectDatabase(); const raw = await readFile(path.join(__dirname, "..", "data", "scholarships.json"), "utf8"); await Scholarship.deleteMany({}); const inserted = await Scholarship.insertMany(JSON.parse(raw)); console.log(Seeded ${inserted.length} scholarships.); process.exit(0); }
seed();
运行命令:
npm run seed
将 JSON 视为示例数据。知名项目的名称能让教程显得更真实。它们也要求明确说明学生必须在官方网站上核实所有数字和日期。applicationUrl 字段的存在是为了让提醒信息有地方指向。
如果之后用实时数据源替换 JSON,请保持相同的数据结构。MCP 工具不应关心文档的来源。
## 如何测试服务器
启动 MongoDB,填充数据,然后启动服务:
npm run seed npm start
你应该看到:
Scholarship research MCP server listening on http://127.0.0.1:3000/mcp
### 健康检查
curl -s http://127.0.0.1:3000/health
返回 JSON 状态:ok 表示 Express 已启动。但这不代表 MCP 连接正确。要验证连接,请发送 JSON-RPC 请求。
### 列出工具
curl -s -X POST http://127.0.0.1:3000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
响应是一个 SSE 事件,其 data: 行包含 JSON-RPC 结果。你应该看到所有九个工具,每个工具都带有从 Zod 派生的 JSON Schema。
Accept 请求头很重要。MCP Streamable HTTP 可以返回 JSON 或事件流。同时请求两者是兼容的选择。
### 调用工具
curl -s -X POST http://127.0.0.1:3000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc":"2.0", "id":2, "method":"tools/call", "params": { "name": "search_scholarships", "arguments": { "fieldOfStudy": "computer science", "limit": 3 } } }'
你应该得到一个编号列表,包含 id、截止日期和 GPA 最低要求。复制其中一个 id 并传递给 get_scholarship 使用。
一旦你开始调用多个方法,用 Node 编写一个小的烟雾测试比直接使用 curl 更加友好。解析 data: 行,然后打印 result.content[0].text。仓库中包含 scripts/smoke-test.js 用于此目的。
### 检查工具
MCP 检查工具是服务器的官方 GUI。将其指向 http://127.0.0.1:3000/mcp,你可以列出工具、填写参数,并在没有主机应用干扰的情况下读取资源。
当主机“看不到”你的服务器时使用检查工具。如果检查工具能工作但主机不能,问题出在主机配置中。如果检查工具失败,问题出在你的进程。
## 如何连接 Cursor 和 Claude Desktop
保持 npm start 运行。HTTP 上的 MCP 是一个实时服务器,而不是一次性 CLI。
### Cursor
在 Cursor 的 MCP 设置中添加一个服务器条目。Streamable HTTP 服务器的配置如下:
{ "mcpServers": { "scholarship-research": { "url": "http://127.0.0.1:3000/mcp" } } }
如果 Cursor 缓存了之前的失败连接,请重新启动 MCP 会话。然后提问:
> 我是一名在美国攻读计算机科学的新生,GPA 3.6。使用奖学金研究工具创建一个简短列表和一个六周计划。
你应该看到主机调用了 match_scholarships 或 search_scholarships,然后对感兴趣的结果调用 get_scholarship,最后可能调用 save_scholarship。如果从未调用过任何工具,说明服务器实际上并未连接。在修改代码前,请先在 Cursor 中检查 MCP 日志。
如果提示菜单中出现相关提示,也可以从主机的提示菜单中调用 research-plan 提示。
### Claude Desktop
Claude Desktop 的配置文件位于:
- macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
- Windows: %APPDATA%\Claude\claude_desktop_config.json
Claude Desktop 优先使用 stdio。通过 mcp-remote 连接 HTTP 服务器:
{ "mcpServers": { "scholarship-research": { "command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:3000/mcp"] } } }
保存文件后重启 Claude Desktop。奖学金工具应该会出现在工具列表中。
不要在 Claude 配置中填写 MongoDB 凭据。Node 进程已经加载了 .env 文件。主机只需要 URL 即可。
## 研究会话的执行流程
以下是一个针对种子目录的真实会话示例。学生是计算机科学专业的一名第一代本科生,美国公民,GPA 3.6。
主机使用该档案调用 match_scholarships。服务器返回排名结果。由于不同原因,女性科技奖项和第一代计划都得分较高,而仅限研究生的奖项从未出现。
主机随后对前两名 ID 调用 get_scholarship。每个结果都包含官方 URL、要求列表和截止日期。这时应该告诉学生打开 URL。模型不应是资格判断的最终依据。
如果某个奖项值得申请,主机将使用研究员 ID(如 ada)和状态 saved 调用 save_scholarship。之后可以设置 applying 状态。list_saved_scholarships 是新聊天恢复短名单的方式。MongoDB 的持久性正是此处的关键。没有它,每次对话都必须从零开始。
add_research_note 用于处理复杂的人类细节:"请在10月1日前向陈博士请求推荐信"。get_upcoming_deadlines 是每周的截止日期扫描。compare_scholarships 用于学生有两名最终候选人时,需要同时查看金额、GPA 和要求的场景。
research-plan 提示封装了这个流程。它会将档案注入用户消息,指示模型先调用工具,再生成每周计划。如果在未连接服务器时调用该提示,会得到一篇通用文章。如果在运行此服务器时调用,会得到具体奖项和真实日期。
这就是产品:一个模型可查询的目录,一个不会遗忘的短名单,以及让工作流程可重复的提示。
## 匹配逻辑的运作方式
有必要放慢匹配过程的节奏,因为这是人们容易直接交给模型处理的部分。
假设学生情况如下:
- GPA 3.6
- 计算机科学专业
- 本科生
- 美国公民
- 第一代
- 女性
匹配器会遍历所有开放奖项。
一个要求最低 GPA 3.3、列出计算机科学领域且面向美国的女性科技奖学金得分很高:教育背景、GPA、公民身份、女性专属标志和领域都符合。一个面向所有领域的第一代计划也得分很高,因为任何领域都算匹配。即使标题相关,仅限研究生的奖项也会被跳过。即使其他条件都符合,GPA 3.8 的门槛也会被跳过。
工具随后会返回带有原因的排名结果:
- Palantir Women in Technology Scholarship — 得分 90
原因:教育水平匹配;GPA 3.6 达到最低要求 3.3;国籍符合资格;仅限女性的奖项匹配;研究领域匹配
模型仍然可以围绕这一点撰写一段温暖的文字。它不应该成为决定资格的决定性因素。
如果以后需要扩展,保持相同的拆分方式。新的资格规则应放在服务中。新的文字内容应放在提示中。
## 下一步可以构建的内容
你目前拥有的服务器已经足够使用。它也可以作为更严肃的研究工具的基础。
首先,你可以将种子文件替换为实时数据源。官方渠道如 Grants.gov 和高校维护的列表比抓取商业聚合器更安全。保持你的数据结构。编写一个导入器,通过稳定的外部 ID 进行插入或更新。
你也可以添加身份验证。createMcpExpressApp 可以与 requireBearerAuth 配合使用。从经过验证的令牌中映射 researcherId,而不是通过工具参数。Express 适配器记录了该中间件。
尝试添加全文搜索功能。数据结构中已经包含了文本索引。对于大型目录,Atlas Search 或专用搜索引擎的效果会优于一堆正则表达式过滤器。
你可以跟踪文档,而不仅仅是备注。为转录稿、推荐状态和文章草稿创建一个文档集合,可以将短名单转变为申请跟踪器。
你还可以围绕匹配器编写测试。资格代码是那些无声错误影响用户的区域。一个包含个人资料和预期包含/排除列表的表格将物超所值。
你还可以部署它。在笔记本电脑上绑定到 127.0.0.1。如果你将这个服务部署到网络中,请设置 allowedHosts、终止 TLS 并要求 bearer token。一个开放的 MCP 服务器相当于一个带有友好英文界面的开放数据库。
## 结论
在本指南中,你构建了一个奖学金研究 MCP 服务器,它不仅仅是一个玩具工具列表。
你将奖项、短名单和备注存储在 MongoDB 中。你通过 MCP 工具暴露了搜索、匹配和研究跟踪功能,其中 Zod 模式允许主机向模型广告这些功能。你为目录添加了资源,为可重复的工作流添加了提示。你通过 Streamable HTTP 使用 Express 提供了所有功能,包括 SDK 为本地主机启用的 Host 头检查。
这种模式可以迁移。任何包含目录和个人工作集的研究流程都可以使用相同的三层结构:一个拥有规则的服务、一个注册原语的 McpServer 工厂,以及一个小型 Express 应用程序来实现协议。
如果你从本教程中只带走一个想法,请带走这个:让模型撰写计划,让你的服务器决定什么才是真实的。
示例目录仅用于学习。在申请任何奖学金之前,请务必在其官方申请页面上确认每个奖学金的信息,并在告诉其他人申请之前也进行确认。
阅读更多文章。
如果这篇文章对你有帮助,请分享它。
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始
ADVERTISEMENT