freeCodeCamp.org

How to Build a Scholarship Research MCP Server with Node.js, Express, and MongoDB

8.5内容质量

TL;DR · AI 摘要

本文详解如何用Node.js、Express和MongoDB构建符合MCP协议的奖学金研究服务器,实现AI与数据库的协同工作。

核心要点

  • MCP协议通过JSON-RPC实现AI与工具的数据交互,支持多客户端统一接入
  • 项目包含9个MCP工具实现搜索、匹配、截止日期跟踪等功能
  • MongoDB存储奖学金目录、书签和研究笔记,支持流式HTTP服务

结构提纲

按章节快速跳转。

  1. 揭示奖学金申请流程的复杂性及AI辅助的局限性,引出MCP协议解决方案

  2. 定义MCP作为AI应用与外部工具的开放协议标准,类比USB-C接口

  3. 分层架构包含数据层(JSON-RPC)和工具层(9个MCP工具)的实现方案

  4. 详细说明MongoDB数据建模、Express服务搭建及MCP工具注册流程

  5. 涵盖本地Docker部署方案及Cursor/Claude Desktop客户端对接方法

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • MCP奖学金服务器架构
    • 协议层
      • JSON-RPC数据传输
      • 工具注册接口
    • 数据层
      • MongoDB存储
      • 奖学金目录
      • 书签集合
    • 应用层
      • Express服务
      • MCP工具集

金句 / Highlights

值得收藏与分享的关键句。

#Node.js#Express#MongoDB#MCP协议
打开原文

如何使用 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:

code
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/listtools/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 合约显而易见。

架构如何协同工作

最终项目结构如下:

code
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。

code
mkdir scholarship-research-mcp-server
cd scholarship-research-mcp-server
npm init -y

打开 package.json 并设置 "type": "module"。然后安装 SDK、Express、Mongoose、Zod 和 dotenv:

code
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express mongoose dotenv zod

v2 版本将 SDK 拆分为多个包:

  • @modelcontextprotocol/server 包含 McpServer 类和 createMcpHandler
  • @modelcontextprotocol/express 提供 createMcpExpressApp,包含 DNS 重绑定防护
  • @modelcontextprotocol/node 将 Web 标准处理器适配到 Node 的 req/res

创建 .env 文件:

code
MONGODB_URI=mongodb://127.0.0.1:27017/scholarship_research
PORT=3000
HOST=127.0.0.1

添加 .gitignore 文件排除 node_modules 和 .env:

code
node_modules/
.env

你的源码结构可以保持简洁:

code
src/
  index.js
  config.js
  db.js
  models/
  services/
  mcp/
  utils/
data/
  scholarships.json
scripts/
  seed.js
  smoke-test.js

src/config.js 使用默认值读取环境变量:

code
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 就足够了:

code
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 文本。

code
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 不同。

保存的奖学金

研究列表是个人与目录条目之间的关联。

code
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 是一个普通字符串。对于教程来说,这样已经足够。在生产环境中应从认证令牌中获取该值。

研究笔记

笔记作为独立集合存储,这样一份奖学金可以对应多个笔记。

code
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 接口复用。

搜索

搜索功能是过滤器构建器。每个可选参数会添加一个条件。开放或滚动录取的奖项会保留在结果集,已关闭的截止日期会被过滤。

code
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 处理器会在每次请求时调用它。

javascript
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 时推断类型。

javascript
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)); }, );

code

将描述视为模型唯一能获取的文档。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, }; }

code

对于领域错误(如“未找到奖学金”)返回 isError: true。仅对意外失败抛出异常。规范对此类错误的处理方式不同。来自 Zod 的验证错误永远不会到达你的处理程序,SDK 已将其转换为 isError 结果。

为快速浏览聊天记录的人格式化列表。在每一行都包含 MongoDB 的 id。后续工具需要这个 id,而模型无法生成有效的 ObjectId。

### 完整工具集

服务器注册了九个工具:

工具

功能描述

search_scholarships

code

筛选目录

get_scholarship

code

返回完整记录

match_scholarships

code

根据学生档案对奖项进行评分

save_scholarship

code

收藏奖项

list_saved_scholarships

code

显示备选列表

add_research_note

code

添加备注

list_research_notes

code

读取备注

get_upcoming_deadlines

code

截止日期窗口

compare_scholarships

code

并排比较两个或三个 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), }, ], }; }, );

code

资源模板可以覆盖一组 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} 的奖学金。, }, ], }; }, );

code

模板需要定义 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}

首先使用奖学金研究工具查找真实奖项。然后生成候选名单、每周计划和风险分析。从工具结果中引用实际奖学金名称和日期。`, }, }, ], }), );

code

注意指令 "使用奖学金研究工具"。提示不能替代工具。它是一个脚本,能提高工具使用概率并使输出格式更一致。

第二个提示 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();

code

逐个解释每个辅助函数的作用。

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); });

code

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" } }

code

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));

code

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();

code

运行命令:

npm run seed

code

将 JSON 视为示例数据。知名项目的名称能让教程显得更真实。它们也要求明确说明学生必须在官方网站上核实所有数字和日期。applicationUrl 字段的存在是为了让提醒信息有地方指向。

如果之后用实时数据源替换 JSON,请保持相同的数据结构。MCP 工具不应关心文档的来源。

## 如何测试服务器

启动 MongoDB,填充数据,然后启动服务:

npm run seed npm start

code

你应该看到:

Scholarship research MCP server listening on http://127.0.0.1:3000/mcp

code

### 健康检查

curl -s http://127.0.0.1:3000/health

code

返回 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"}'

code

响应是一个 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 } } }'

code

你应该得到一个编号列表,包含 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" } } }

code

如果 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"] } } }

code

保存文件后重启 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 的门槛也会被跳过。

工具随后会返回带有原因的排名结果:
  1. Palantir Women in Technology Scholarship — 得分 90

原因:教育水平匹配;GPA 3.6 达到最低要求 3.3;国籍符合资格;仅限女性的奖项匹配;研究领域匹配

code

模型仍然可以围绕这一点撰写一段温暖的文字。它不应该成为决定资格的决定性因素。

如果以后需要扩展,保持相同的拆分方式。新的资格规则应放在服务中。新的文字内容应放在提示中。

## 下一步可以构建的内容

你目前拥有的服务器已经足够使用。它也可以作为更严肃的研究工具的基础。

首先,你可以将种子文件替换为实时数据源。官方渠道如 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