← 返回蜂巢洞察

如何使用Node.js、Express和MongoDB构建一个用于奖学金申请研究的MCP服务器

寻找奖学金信息其实是一项需要系统地进行研究的任务,而不仅仅是通过简单的搜索来完成的。你需要根据学科领域、平均成绩、国籍以及截止日期等条件来筛选各类奖学金信息,然后列出候选名单,并对相关申请材料及推荐人的意见做好记录。一周后,你再回顾这些信息,尝试回忆自己当初为什么选择保存某个具体的奖学金项目。 人工智能助手可以帮助完成这一工作流程,但前提是它必须能够访问真实的数据库,并能保留你之前所做的筛选和决策结果。聊天记录并不能替代数据库;而那些被错误设定的截止日期反而会比根本没有截止日期更糟糕。 Model Context Protocol (MCP) 为人工智能应用程序提供了获取这类信息的标准接口。在

寻找奖学金信息其实是一项需要系统地进行研究的任务,而不仅仅是通过简单的搜索来完成的。你需要根据学科领域、平均成绩、国籍以及截止日期等条件来筛选各类奖学金信息,然后列出候选名单,并对相关申请材料及推荐人的意见做好记录。一周后,你再回顾这些信息,尝试回忆自己当初为什么选择保存某个具体的奖学金项目。

人工智能助手可以帮助完成这一工作流程,但前提是它必须能够访问真实的数据库,并能保留你之前所做的筛选和决策结果。聊天记录并不能替代数据库;而那些被错误设定的截止日期反而会比根本没有截止日期更糟糕。

Model Context Protocol (MCP)为人工智能应用程序提供了获取这类信息的标准接口。在这个教程中,你将使用Node.js、Express和MongoDB来构建一个用于奖学金搜索的MCP服务器。

完成开发后,Cursor、Claude Desktop或任何其他支持MCP协议的工具都能够用来搜索奖学金信息、将这些信息与学生的个人资料进行匹配、保存候选名单以及记录研究过程中的各种细节。模型本身负责处理逻辑推理部分,而数据则存储在服务器上。

你将构建以下内容:

  • 一个包含所有奖学金信息的MongoDB数据库,以及用于保存候选名单和备注的集合

  • 九种用于搜索、匹配、查看截止日期、进行数据比较以及追踪研究进展的MCP工具

  • 一些资源文件,使客户端能够在不使用任何专用工具的情况下直接查阅数据库信息

  • 一些提示功能,能够帮助用户将学生的个人资料转化为完整的研究计划

  • 一个基于Express框架的应用程序,用于通过Streamable HTTP协议提供MCP服务

本项目中的示例数据库其实是一个教学用数据集,其中包含的金额、日期和资格要求等信息都被简化了。在实际申请之前,请务必先查看官方的申请页面以确认所有细节。

你需要的准备

你需要熟悉JavaScript以及基本的Express路由配置,不过不需要具备使用MCP协议的先前经验。

需要安装以下软件:

对于MongoDB来说,使用Docker就足够了:

docker run -d --name mongo -p 27017:27017 mongo:7

目录

什么是模型上下文协议?

MCP是一种开放协议,它允许人工智能应用程序通过一个共同的契约与外部工具和数据源进行交互。Anthropic在2024年推出了这一协议,如今它已被作为一项开放标准来维护。

Anthropic将MCP描述为“人工智能应用程序的USB-C接口”:同一个接口,可以连接多种不同的服务或系统。(来源:介绍模型上下文协议)你无需分别为Cursor、Claude Desktop或自定义代理编写集成代码,只需实现一次这个协议即可。

官方架构说明将MCP分为两个层次:

  • 数据层采用JSON-RPC 2.0规范。客户端和服务器会通过诸如tools/listtools/call之类的请求进行交互。

  • 传输层负责传递这些消息。本地服务器通常使用标准输入输出接口,而远程或长时间运行的服务器则会使用Streamable HTTP协议。

本教程采用Streamable HTTP协议,因为Express本身就是一个HTTP服务器,而且我们希望任何客户端都能访问这些功能。

主机、客户端与服务器

在每一个MCP系统中,都会出现三种角色:

  • 主机就是人工智能应用程序本身。Cursor和Claude Desktop都属于这一类别。

  • 客户端存在于主机内部。每当有新的服务器连接时,主机会为该服务器创建一个对应的客户端。

  • 服务器则是你的程序。它负责展示可用的工具、资源以及提示信息,并处理各种请求。

你的奖学金申请处理程序就属于服务器端。你永远不会直接与模型SDK进行交互,这些工作都是由主机来完成的。

工具、资源与提示信息

MCP服务器提供了三种基本功能,你会用到所有这三种功能。

工具指的是具体的操作指令。模型会决定是否执行这些操作,就像在普通的API中调用函数一样。搜索、保存和比较等功能都属于这一类别。

资源是主机可以读取并用作上下文的数据。目录URI和针对特定奖学金申请的资源URI都属于这一类别。模型无需执行任何操作就能获取这些资源。

提示信息是带有名称的模板,人们通常会通过命令行或菜单来使用它们。例如,“研究计划”这样的提示信息就属于这一类别,因为它是你需要特意启动的一个工作流程。

这种功能划分非常重要。如果把所有内容都归类为工具,模型就需要自行判断何时去查找所需的信息;而资源与提示信息的存在,则能让主机更加灵活地控制整个流程。

为什么需要奖学金研究服务器?

天气相关的MCP演示只需要一次API调用即可完成,但奖学金申请处理这类应用则更接近于一个真正的产品。

  • 目录必须具备可查询性。关键词搜索、平均绩点筛选功能以及截止日期设置等数据都应存储在数据库中。

  • 工作流程必须具有持久性。保存下来的候选名单和研究笔记应该能够在新的聊天会话中继续被使用。

  • 资格审核的标准应该是逻辑性的,而不是描述性的文字。平均绩点的最低要求就是一个具体的数字;“仅限第一代家庭成员”这一条件则是一个布尔值。这些规则都应该被编写成代码,这样系统就无法随意制造符合条件的结果。

  • 输出结果必须能够被核实。学生应该能够通过访问官方网址来验证所有信息的一致性。

MongoDB非常适合用于实现这一需求。每项奖学金信息都对应一份文档,其中会包含学习领域、国籍等相关信息的嵌套数组,以及其他所需填写的信息。保存下来的记录和备注则属于单独的集合,这些集合会引用到主目录中的相应数据。

你也可以选择使用公共API来代替直接存储文档这种方式,这也是一个不错的解决方案。首先创建自己的目录结构,可以让教程内容更加完整,同时也能让MCP协议的结构更加清晰明了。

架构是如何协同工作的

最终完成的项目结构如下所示:

MCP主机(Cursor或Claude Desktop)
        |
        |  Streamable HTTP POST接口 /mcp
        v
Express应用程序(createMcpExpressApp)
        |
        |  createMcpHandler处理函数工厂
        v
McpServer工具/资源/提示模块
        |
        v
奖学金服务模块
        |
        v
MongoDB数据库:scholarships、savedScholarships、researchnotes集合

在开始编写代码之前,有几点设计原则值得注意:

MCP处理函数是无状态的。对于每个HTTP请求,SDK都会重新运行服务器工厂函数。这是Streamable HTTP推荐的v2实现方式。不要将任何状态信息保存在McpServer实例中,而应该将其存储在MongoDB数据库中。

数据库连接是跨进程的。如果在每个请求时都进行连接操作,会显得效率低下且毫无意义。因此你只需要在程序启动时建立一次连接,之后就可以通过工具池来复用这个连接。

故意将HTTP接口设计得简单一些。其中/health接口是供开发者用于检查系统状态的,而/mcp接口则是用于处理与奖学金相关的数据的。除非以后有需要,否则没有必要在相同的数据前面添加REST API层。

如何设置项目环境

首先创建一个文件夹,并初始化一个Node.js项目。由于MCP SDK优先支持ESM模块格式,因此必须使用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 zod

在v2版本中,SDK被分成了多个独立的包:

  • @modelcontextprotocol/server包含了McpServer类以及createMcpHandler处理函数。

  • @modelcontextprotocol/express提供了createMcpExpressApp应用程序,其中还包含了DNS重绑定保护机制。

  • @modelcontextprotocol/node负责将Web标准的处理函数适配到Node.js的req/res对象上。

创建一个.env文件,用于配置数据库连接信息:

MONGODB_URI=mongodb://127.0.0.1:27017/scholarship-research
PORT=3000
HOST=127.0.0.1

还需要创建一个.gitignore文件,将node_modules文件夹和.env文件排除在版本控制范围之外。

这样的项目结构可以让代码文件的数量保持在较少的范围内。

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

src/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 服务器来说,如果数据库连接失败,会比启动过程出错更难进行调试。

如何对奖学金数据进行建模

你需要创建三个集合。

Scholarship

这个集合用于存储奖学金的相关信息。其中存储的应该是数据库查询引擎实际会使用的字段,而不是纯文本格式的数据。

import mongoose from "mongoose";

const scholarshipSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true },
    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({
  title: "text",
  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意味着提供该奖学金的机构没有设定最低GPA要求,这与0有所不同。

已保存的奖学金信息

“研究列表”实际上表示一个人与某项奖学金信息之间的关联关系。

const savedScholarshipSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose.SchemaTypes 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 });

由于存在唯一索引,save_scholarship这个操作是幂等的。重复保存同一项奖学金信息只会更新其状态,而不会生成重复记录。

researcherId只是一个普通的字符串。在开发阶段使用这个字段就可以了;但在生产环境中,应该从认证令牌中获取该值。

研究笔记

研究笔记是单独存储的,因此一项奖学金可以对应多条研究笔记。

const researchNoteSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose SchemaTypes ObjectId,
      ref: "Scholarship",
      required: true,
    },
    body: { type: String, required: true, trim: true },
  },
  { timestamps: true },
);

现在你已经拥有了奖学金信息目录、候选名单以及研究笔记功能。这些就是MCP工具所要处理的全部数据结构。

如何编写奖学金服务相关代码

请不要在MCP层中使用MongoDB查询语句。工具应该调用相应的服务接口,获取纯对象格式的数据,然后再进行文本格式化处理。这样就可以确保相同的逻辑可以在种子脚本、测试用例或未来的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: escapeRegexfilters.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 已经完成了资格筛选,你只需要调整这些奖学金的显示顺序而已。

匹配

“匹配”与“搜索”是不同的。“搜索”是指 “找出符合特定条件的文档”,而“匹配”则意味着 “为符合条件的学生分配所有可申请的奖学金”。

该系统会先加载所有可申请的奖学金信息,然后应用一系列严格的筛选条件,并对每个奖学金进行评分:

筛选条件 对应效果
教育程度不匹配 跳过该奖学金
平均绩点低于最低要求 跳过该奖学金
国籍不符 跳过该奖学金
仅适用于第一代家庭学生,而当前学生不符合条件 跳过该奖学金
仅适用于女性学生,而当前学生不符合条件 跳过该奖学金
教育程度匹配 +20 分
平均绩点符合要求 +15 分
国籍符合要求 +15 分
所学专业匹配 +30 分
优先考虑的国家与学生所选国家匹配 +10 分
奖学金金额符合学生的最低要求 +10 分
仅适用于第一代家庭学生或女性学生,而当前学生符合条件 +10 分

这些严格的筛选条件可以避免给学生带来虚假的希望;而那些基于评分结果的排序则有助于确定奖学金的显示顺序。每个查询结果还会返回一个 `reasons` 数组,这样系统就可以解释为什么某个奖学金会被选中,而不是随意给出理由。

最后一点就是:之所以要把匹配逻辑放在服务器端处理,原因就在于此。如果你只返回原始文档,模型有时会“好心”地包含一些学生根本无法申请的奖学金信息。而通过返回 `score` 和 `reasons`,这些解释就可以基于代码本身来进行说明了。

保存、备注、截止日期、比较

其余的功能都比较简单:

  • saveScholarship 方法会按照 `(researcherId, scholarshipId)` 的键值对来更新数据库记录

  • addResearchNote 方法会在确认奖学金存在之后插入备注信息

  • getUpcomingDeadlines 方法会查询从现在起到 “现在 + N 天” 之间的所有截止日期

  • compareScholarships 方法会加载两到三份奖学金信息,并返回每份信息的相同字段内容

在进行查询之前,一定要使用 `mongooseTypes ObjectId isValid` 方法来验证 MongoDB 中的 ID 是否有效。有时候,大型语言模型可能会错误地提供标题信息而不是 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,SDK还会自动推断参数类型。

import * as z from "zod/v4";

server.registerTool(
  "search_scholarships",
  {
    title: "搜索奖学金",
    description:
      "可以通过关键词、学习领域、教育层次、国籍、国家、平均绩点以及奖励金额来搜索奖学金信息。",
    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: zboolean().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格式后依然有效。正是通过这种方式,SDK才能理解fieldOfStudy字段的具体含义。

title是供人类用户理解的标签,而description则是模型所依赖的规范。这两者并不是相同的字符串。

注释

注释并不会改变SDK运行该工具的方式,但开发人员可以根据这些注释来决定在处理某些操作时应该采取何种谨慎程度。

  • 对于searchgetmatchlistcompare以及deadlines这些操作,设置readOnlyHint: true

  • 对于saveadd-note操作,将 readOnlyHint: false

  • 由于数据库会为每个保存的操作生成唯一的索引,因此对save操作设置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 对比两到三项奖学金的信息

这些工具已经足以满足进行研究所需的全部流程:查找、查看、匹配、保存、添加备注以及进行比较。

请不要添加delete_everything这种具有破坏性的工具,因为这类工具需要额外的确认步骤,并且不符合当前的工作流程要求。

如何展示资源与提示信息

工具并不是主机获取上下文的唯一途径。

资源

静态资源是指那些URI地址固定的资源,奖学金目录就属于这类资源:

server.registerResource(
  "scholarship-catalog",
  "scholarship://catalog",
  {
    title: "奖学金目录",
    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://catalog这样的URL就会产生歧义。

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",
        }),
      };
    },
  ),
  {
    title: "奖学金记录详情",
    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",
  {
    title: "奖学金研究计划",
    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}

请先使用奖学金研究工具查找可申请的奖项,然后生成一份包含具体安排和潜在风险的详细计划。请确保列出的奖学金名称和申请日期均来自工具查询的结果。`,
        },
      },
    ],
  }),
);

注意其中的提示:“请使用奖学金研究工具”。提示信息本身并不能替代这些工具,它只是提供了一种脚本,使人们更有可能使用这些工具,并使输出结果更加统一。

另一个示例提示application-checklist,它会接收一个奖学金编号,然后要求用户提供相关文件列表以及一份倒计时日历。这类重复性工作正是MCP提示信息所擅长的。

在许多系统中,提示信息中的参数通常是字符串形式,即使其实际值应该是数字也是如此。将gpa以字符串的形式输入,可以避免在使用slash-command形式进行操作时出现“期望的是数字,但实际上收到的是字符串”这样的错误。

如何通过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(`奖学金研究MCP服务器正在http://${config.host}:${config.port}/mcp上运行`);
  });
}

start();

让我们逐一了解这些辅助函数的用途。

createMcpHandler接收一个函数作为参数,该函数会返回一个新的McpServer实例。这个处理程序提供了符合Web标准的fetch方法——这种处理方式与你在Cloudflare Worker中使用的处理程序是完全相同的。

toNodeHandler会将这个fetch方法适配到Express的(req, res)请求模型中。由于createMcpExpressApp已经调用了express.json(),因此你会将req.body作为第三个参数传递给toNodeHandler;如果你省略了这个参数,适配器会尝试读取Express已经处理过的请求数据。

createMcpExpressApp实际上就是添加了JSON解析功能以及主机/来源地址检查功能的express()。进行这些检查的原因是为了避免DNS重绑攻击:恶意网站可能会将自己的域名指向127.0.0.1,如果没有进行主机地址检查,浏览器就会将本地的MCP服务器视为同源资源。因此,默认的绑定地址就是127.0.0.1。更多关于这一点的信息,请参考Express服务指南

app.all("/mcp", ...)这种设计是经过深思熟虑的:Streamable HTTP协议在发送JSON-RPC请求时使用POST方法,而在传输SSE数据流时则使用GET方法。如果只注册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工具在空数据库上运行,它会返回“没有匹配的奖学金信息”,这种结果虽然正确,但会给用户留下不好的第一印象。

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(`已添加了${inserted.length}条奖学金记录。`);
  process.exit(0);
}

seed();

运行如下命令:

npm run seed

请将其中的JSON数据视为示例数据。使用知名程序的名称可以让教程显得更加真实可信。同时,这也提醒学生必须仔细核对官方网站上提供的所有数字和日期信息。applicationUrl字段的存在是为了让提示信息有明确的指向对象。

如果之后你用实际的数据替换了JSON文件,请确保保持相同的数据结构。MCP工具并不关心这些文档的来源。

如何测试服务器

首先启动MongoDB,然后运行`seed`命令,最后启动应用程序:

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数据中`status`字段值为`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格式。

`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.js测试脚本会比直接使用curl命令更方便。解析响应结果中的`data:`字段,然后输出`result.content[0].text`的内容。仓库中提供了`scripts/smoke-test.js`这个测试脚本。

检查工具

MCP检查工具是专门用于服务器管理的官方图形界面工具。将它指向`http://127.0.0.1:3000/mcp`,你就可以列出所有工具、设置参数,并查看相关资源信息,而不会受到主机应用程序的干扰。

当主机无法检测到你的服务器时,可以使用MCP检查工具进行诊断。如果检查工具能够正常使用,而主机却无法检测到服务器,那么问题出在主机的配置设置上;如果检查工具也无法正常工作,那么问题就出在你的应用程序本身了。

如何连接Cursor与Claude Desktop

请保持`npm start`命令处于运行状态。通过HTTP运行的MCP是一个实时服务器,而不是用于一次性使用的命令行工具。

Cursor

在Cursor的MCP设置中添加一个服务器条目。Streamable HTTP服务器的配置如下:

{
  "mcpServers": {
    "scholarship-research": {
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}

如果Cursor之前有过失败的连接尝试,需要重新启动MCP会话。然后输入如下内容:

我是一名在美国就读计算机科学专业的一年级本科生,平均绩点为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更倾向于使用标准输入输出方式。你可以使用mcp-remote将你的HTTP服务器与Claude Desktop连接起来:

{
  "mcpServers": {
    "scholarship-research": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:3000/mcp"]
    }
  }
}

保存配置文件后,请重新启动Claude Desktop。此时奖学金搜索工具应该会出现在工具列表中。

请不要在Claude的配置文件中填写MongoDB的登录凭据。Node进程已经加载了`.env`文件,主机只需要知道URL地址即可。

研究会话的运行流程

以下是一个针对特定学生信息进行的真实案例分析:该学生是一年级计算机科学专业本科生,美国公民,平均绩点为3.6。

主机会使用该学生的信息调用`match_scholarships`函数,服务器会返回排名靠前的结果。其中,“女性科技奖”和“针对第一代大学生的奖学金项目”的得分都比较高,不过原因各不相同;而“仅限研究生申请的奖学金”则从未出现在结果列表中。

随后,主机会使用排名前两位的奖学金信息调用`get_scholarship`函数。每个搜索结果都会包含官方网址、申请要求以及截止日期。这时就可以让学生去访问这些网址了。不过,模型生成的推荐结果不应成为判断申请人是否符合申请条件的唯一依据。

如果某项奖学金值得申请,主机会使用相应的`researcherId`(例如`ada`)和`status`字段(设置为`saved`)来保存该奖学金信息。之后还可以将状态改为`applying`。通过调用`list_saved_scholarships`函数,新的聊天对话就可以直接获取到候选名单。在这里,MongoDB的持久化存储功能发挥了关键作用——如果没有这种机制,每次对话都会从头开始进行。

add_research_note用于处理那些繁琐的人力资源相关事宜,比如“需要在10月1日前向陈博士征求推荐意见”。get_upcoming_deadlines用于每周汇总各项截止日期。而compare_scholarships则适用于那种学生已经获得了两个奖学金最终候选资格的情况,它可以帮助学生在同一界面中查看这些奖学金的金额、平均绩点以及申请要求等信息。

research-plan这个功能可以自动生成循环性的学习计划。它会将用户的个人资料融入到消息中,从而指示模型先使用相关工具进行数据分析,然后再生成一份周规划。如果你在没有连接服务器的情况下使用这个功能,那么得到的只会是一篇普通的文章;但如果你在服务器运行的情况下使用它,那么生成的规划中就会包含具体的奖项名称和截止日期等信息。

这就是最终的产品:一个模型可以查询的目录、一份学生不会忘记的候选名单,以及一些能够确保工作流程可重复执行的提示功能。

匹配逻辑的工作原理

在匹配环节上花些时间仔细考虑是很有必要的,因为这部分内容往往是人们愿意让模型来处理的。

假设有一名学生如下这些信息:

  • 平均绩点为3.6

  • 专业是计算机科学

  • 目前还是本科生

  • 是美国公民

  • 属于第一代移民家庭

  • 性别为女性

匹配系统会逐一检查所有符合条件的奖学金。

例如,有一项针对女性的科技领域奖学金,要求平均绩点至少为3.3,专业必须是计算机科学,并且申请人必须是美国公民。由于这位学生的各项条件都符合要求,因此这项奖学金被优先考虑。另外,那些仅面向研究生的奖学金也会被排除在外,即使它们的名称看起来似乎很相关;同样,平均绩点要求为3.8的奖学金也会被忽略,即使其他条件都符合。

最终,该工具会返回一份按排名排列的结果列表,并附上相应的理由:

1. Palantir女性科技领域奖学金 —— 得分90
   原因:教育背景符合要求;平均绩点3.6满足最低要求3.3;具备美国公民身份;是专为女性设立的奖项;专业也相符

模型仍然可以在这些结果的基础上再添加一些描述性文字,但这个判断资格的过程不应该由模型来完成。

如果你以后想要对这个系统进行扩展,请保持现有的结构划分:新的资格审核规则应该放在服务端中处理,而新的描述性内容则应该通过提示功能来呈现。

接下来你可以开发什么

你目前拥有的这个服务器已经足够使用了,它也可以作为开发更高级的研究工具的基础。

首先,你可以用实时数据源替换现有的初始数据文件。像Grants.gov这样的官方信息来源,以及各高校自己维护的名单,都比从商业聚合网站获取数据要可靠得多。请保持你的数据结构不变,并编写一个导入程序,以便能够根据稳定的外部标识符来更新数据。

你还可以为这个系统添加身份验证功能。createMcpExpressApp可以与requireBearerAuth配合使用。可以将经过验证的令牌中的researcherId信息作为参数传递给系统,而不是通过其他方式提供。关于如何实现这一功能的详细文档,可以在Express适配器文档中找到。

可以尝试添加全文搜索功能。该数据结构已经包含了文本索引;对于规模较大的目录而言,使用Atlas Search或专用的搜索引擎会比一堆正则表达式过滤器更有效。

你不仅可以跟踪笔记,还可以追踪其他文档。通过创建一个名为`documents`的集合,用来存储转录内容、推荐状态以及文章草稿等信息,就能将候选名单转换成一个有效的任务跟踪系统。

你还可以针对这些匹配机制编写测试用例。在处理资格判断逻辑时,隐藏在代码中的错误往往会造成严重问题;因此,准备一份包含各种配置信息及预期匹配结果的表格是非常有必要的。

最后,你可以将这些功能部署到实际环境中。可以在笔记本电脑上将服务器绑定到`127.0.0.1`地址;如果将其放在网络环境中使用,需要设置`allowedHosts`参数、关闭TLS加密,并要求用户提供身份验证令牌。一个开放式的MCP服务器实际上就是一个带有英文界面、可供大家使用的数据库。

结论

通过本指南的学习,你构建了一个功能完备的学术研究管理平台,而这个平台远不止是一个简单的工具集合而已。

你将各类奖项信息、候选名单以及笔记数据存储在了MongoDB数据库中,并将搜索、匹配以及研究跟踪功能作为MCP工具提供出来;同时利用Zod模式让服务器能够向模型传递相关配置信息。你还为目录管理添加了各种资源,为重复性工作流程提供了相应的提示机制。最后,你通过Express框架和Streamable HTTP协议将整个系统部署上线,其中还包括了对本地主机地址进行验证的功能。

这种架构是通用且可移植的。任何需要使用目录和个人工作区的研究工作流程都可以采用这套三层结构:一层负责制定规则的服务层,一层用于注册各种基础组件的`McpServer`工厂层,以及一层遵循相应协议运行的Express应用层。

如果要从本教程中吸取某个经验的话,那就是:让模型来制定计划,而由服务器来判断哪些信息是真实的。

示例目录仅用于学习用途。在真正申请任何奖学金之前,请务必先在其官方网站上核实所有相关信息;在建议他人申请之前,也同样需要先进行确认。

相关文章

技术实践

如何使用屏幕阅读器来使用 Discord:快速指南

Discord就是这样一种技术:几年前它突然出现,如今已经无处不在。 各种社区、项目和倡议都建立了大量的服务器,而且这类服务器还在不断涌现。在很大程度上,它们已经取代了论坛、聊天室,有时甚至也替代了文档网站和新闻板块的功能。 这种变化究竟是好是坏,还有待讨论,但可以肯定的是,Discord将会长期存在下去。 就可访问性而言,Discord曾经面临过一些问题。多年来,对于使用屏幕阅读器的用户来说,使用这款软件一直非常不便,因为开发者显然没有充分考虑过可访问性的需求。 不过在过去的几年里,这种情况已经得到了显著改善。虽然到2026年时,Discord仍然不是完全适合所有用户使用的工具,但只要掌握了

阅读全文
技术实践

使用OpenTelemetry实现Claude Code的可观测性

像 Claude Code 、 OpenAI Codex 、 Google Antigravity 以及 Cursor 这样的代理编码工具,在日常软件开发中已经变得无处不在。 随着代理系统的不断发展,开发者让这些系统完成的大部分工作都是通过逐个分配子任务来实现的。许多团队也在探索并使用共享的、多租户式的代理基础设施,这种架构的成本不会与某个特定的所有者挂钩。在这种情况下,可观测性就成为了监控基础设施成本的关键因素。 在本指南中,您将了解可观测性的工作原理,然后学习如何启用Claude Code内置的遥测功能,运行后端程序来收集数据,并读取该系统生成的各类指标、日志及追踪信息。这些内容将帮助您更

阅读全文
技术实践

人工智能工程在实践中的应用:人工智能工程师与现场部署工程师如何利用Claude Code、Codex及Gemini来进行开发工作

METR )。 DORA )。 Stack Overflow )。 Anthropic )。 目录 先决条件 什么是AI原生SDLC? 当代码开发成本降低后,瓶颈会出现在哪里? Claude Code、Codex与Gemini CLI:同一个框架,三种不同的工具/术语体系 如何开展规划阶段的工作 如何开展设计阶段的工作 如何开展构建阶段的工作 如何开展测试阶段的工作 如何开展部署阶段的工作 如何开展维护阶段的工作 一名工程师如何负责一个五人的团队 规划阶段 设计阶段 构建阶段 测试阶段 部署阶段 维护阶段 在全面采用AI原生SDLC之前,需要检查的事项清单 结论 接下来应该探索什么 先决条件

阅读全文
技术实践

了解人工智能软件开发生命周期流程——构建智能代理功能的完整指南

也许你可以理解这样的场景:本周,你用了同样的说明四次向别人解释人工智能模型的使用方法。 你反复讲解过团队是如何构建演示文稿的框架的,哪些检查步骤需要在部署之前完成,以及为什么测试数据库并不是文档中提到的那个。 每次你都要把这些内容重新写一遍,每次智能助手也能完成得不错,但每次新的会话开始时,一切都得从零开始。 而这正是 智能助手技能 所要解决的问题。 技能 实际上就是一个文件夹,其中只包含一个名为 Skill.md 的文件。智能助手在启动时会阅读其中的一行总结内容,而只有当真正需要时才会打开完整的说明文件。你只需把解释内容编写一次,将其与代码一起提交,那么团队中的每个智能助手就能访问这些信息,

阅读全文