server.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. // 参考 MCP 服务:按《MCP 接入指南》(docs/guides/mcp-guide.md)编写,演示 AgentServer 支持的各项 MCP 能力。
  2. // 业务是一个虚构的"订单系统",数据在内存中,重启后恢复初始状态。
  3. //
  4. // PORT=8790 REF_TOKEN=secret node server.mjs
  5. //
  6. // 在 AgentServer 后台"MCP 服务"中添加:远程 HTTP,地址 http://<主机>:8790/mcp,请求头 Authorization: Bearer secret
  7. import crypto from 'node:crypto'
  8. import http from 'node:http'
  9. import { z } from 'zod'
  10. import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
  11. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  12. import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
  13. const PORT = Number(process.env.PORT ?? 8790)
  14. const HOST = process.env.HOST ?? '127.0.0.1'
  15. const TOKEN = process.env.REF_TOKEN ?? 'ref-secret'
  16. // ---- 业务数据 ----
  17. const orders = new Map([
  18. ['A1001', { id: 'A1001', customer: '张三', amount: 1299, status: 'paid', items: ['储能电池 5kWh'] }],
  19. ['A1002', { id: 'A1002', customer: '李四', amount: 399, status: 'shipped', items: ['智能网关'] }],
  20. ])
  21. const notes = []
  22. /**
  23. * 长任务的存储:所有连接共用。
  24. * 指南 §5.3:任务要按认证身份归属,不要绑定在 MCP 会话上,否则平台重连(或重启)后查不到。
  25. * 这里只有一个令牌,所以直接共用;有多个调用方时,按令牌区分。
  26. */
  27. class SharedTaskStore extends InMemoryTaskStore {
  28. createTask(params, requestId, request) { return super.createTask(params, requestId, request, undefined) }
  29. getTask(taskId) { return super.getTask(taskId, undefined) }
  30. storeTaskResult(taskId, status, result) { return super.storeTaskResult(taskId, status, result, undefined) }
  31. getTaskResult(taskId) { return super.getTaskResult(taskId, undefined) }
  32. updateTaskStatus(taskId, status, message) { return super.updateTaskStatus(taskId, status, message, undefined) }
  33. listTasks(cursor) { return super.listTasks(cursor, undefined) }
  34. }
  35. const taskStore = new SharedTaskStore()
  36. const taskQueue = new InMemoryTaskMessageQueue()
  37. let betaEnabled = false
  38. function buildServer() {
  39. const server = new McpServer(
  40. { name: 'order-reference', version: '1.0.0' },
  41. {
  42. capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {}, list: {} } },
  43. taskStore,
  44. taskMessageQueue: taskQueue,
  45. // 写给模型看的服务说明:什么时候用哪个工具
  46. instructions: '订单系统。查订单用 get_order;导出报表很慢,用 export_report(长任务);删除类操作会要求确认。',
  47. },
  48. )
  49. // ---- 1. 只读工具 + 结构化输出(指南 §3、§4) ----
  50. server.registerTool('get_order', {
  51. title: '查询订单',
  52. description: '按订单号查询订单的客户、金额、状态和商品。订单号形如 A1001。',
  53. inputSchema: { orderId: z.string().regex(/^A\d{4}$/).describe('订单号,例如 A1001') },
  54. outputSchema: { id: z.string(), customer: z.string(), amount: z.number(), status: z.enum(['paid', 'shipped', 'cancelled']), items: z.array(z.string()) },
  55. annotations: { readOnlyHint: true, openWorldHint: false },
  56. }, async ({ orderId }) => {
  57. const o = orders.get(orderId)
  58. // 错误写给模型看:说清楚原因和下一步怎么做(指南 §3.4)
  59. if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在。可以先用 list_orders 查看现有订单号。` }] }
  60. // 结构化结果同时序列化成一段文本,兼容只看文本的客户端
  61. return { content: [{ type: 'text', text: JSON.stringify(o) }], structuredContent: o }
  62. })
  63. server.registerTool('list_orders', {
  64. title: '订单列表',
  65. description: '分页列出订单(每页最多 20 条),可以按状态筛选。',
  66. inputSchema: { status: z.enum(['paid', 'shipped', 'cancelled']).optional(), cursor: z.string().optional().describe('上一页返回的 nextCursor') },
  67. annotations: { readOnlyHint: true, openWorldHint: false },
  68. }, async ({ status, cursor }) => {
  69. const all = [...orders.values()].filter(o => !status || o.status === status)
  70. const start = cursor ? Number(cursor) : 0
  71. const page = all.slice(start, start + 20)
  72. const next = start + 20 < all.length ? String(start + 20) : null
  73. const structuredContent = { orders: page.map(o => ({ id: o.id, customer: o.customer, status: o.status })), nextCursor: next }
  74. return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }
  75. })
  76. // ---- 2. 写操作与提示(指南 §3.3) ----
  77. server.registerTool('add_note', {
  78. title: '添加备注',
  79. description: '给订单添加一条备注。每次调用都会新增一条(不幂等)。',
  80. inputSchema: { orderId: z.string(), text: z.string().max(500) },
  81. annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  82. }, async ({ orderId, text }) => {
  83. notes.push({ orderId, text, at: new Date().toISOString() })
  84. return { content: [{ type: 'text', text: `已为 ${orderId} 添加备注(共 ${notes.filter(n => n.orderId === orderId).length} 条)` }] }
  85. })
  86. server.registerTool('cancel_order', {
  87. title: '取消订单',
  88. description: '取消订单。已取消的订单再次取消不会有变化(幂等)。这是破坏性操作,平台会要求用户确认。',
  89. inputSchema: { orderId: z.string(), reason: z.string().describe('取消原因') },
  90. annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
  91. }, async ({ orderId, reason }) => {
  92. const o = orders.get(orderId)
  93. if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在` }] }
  94. o.status = 'cancelled'
  95. return { content: [{ type: 'text', text: `订单 ${orderId} 已取消,原因:${reason}` }] }
  96. })
  97. // ---- 3. 调用过程中询问用户(指南 §6) ----
  98. server.registerTool('ship_order', {
  99. title: '安排发货',
  100. description: '为订单安排发货。会请用户填写收件信息。',
  101. inputSchema: { orderId: z.string() },
  102. annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
  103. }, async ({ orderId }) => {
  104. const r = await server.server.elicitInput({
  105. mode: 'form',
  106. message: `请填写订单 ${orderId} 的收件信息`,
  107. requestedSchema: {
  108. type: 'object',
  109. properties: {
  110. name: { type: 'string', title: '收件人', minLength: 1 },
  111. phone: { type: 'string', title: '手机号', minLength: 11, maxLength: 11 },
  112. address: { type: 'string', title: '地址' },
  113. speed: { type: 'string', title: '配送方式', oneOf: [{ const: 'standard', title: '普通' }, { const: 'express', title: '加急' }], default: 'standard' },
  114. insured: { type: 'boolean', title: '保价', default: false },
  115. },
  116. required: ['name', 'phone', 'address'],
  117. },
  118. })
  119. // 三种结果都要处理:提交、拒绝、关闭
  120. if (r.action === 'accept') return { content: [{ type: 'text', text: `已安排发货:${JSON.stringify(r.content)}` }] }
  121. if (r.action === 'decline') return { content: [{ type: 'text', text: '用户拒绝提供收件信息,未发货' }] }
  122. return { content: [{ type: 'text', text: '用户暂未处理,未发货。可以稍后再试' }] }
  123. })
  124. server.registerTool('connect_crm', {
  125. title: '连接 CRM',
  126. description: '连接外部 CRM。需要用户在浏览器中授权(网址模式),不在对话中索要密码。',
  127. inputSchema: {},
  128. annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
  129. }, async () => {
  130. const r = await server.server.elicitInput({ mode: 'url', message: '请在打开的页面中登录 CRM 并授权订单系统访问', url: 'https://crm.example.com/oauth/authorize?client=orders', elicitationId: crypto.randomUUID() })
  131. return { content: [{ type: 'text', text: r.action === 'accept' ? '用户已完成授权' : '用户没有授权' }] }
  132. })
  133. // ---- 4. 长任务(指南 §5) ----
  134. server.experimental.tasks.registerToolTask('export_report', {
  135. title: '导出销售报表',
  136. description: '导出指定月份的销售报表。很慢(演示中按 seconds 参数计时),以长任务方式执行,完成后平台会把结果发给你。',
  137. inputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/).describe('月份,例如 2026-10'), seconds: z.number().int().min(1).max(7200).default(30).describe('演示用:执行多少秒') },
  138. execution: { taskSupport: 'optional' },
  139. annotations: { readOnlyHint: true },
  140. }, {
  141. async createTask({ month, seconds }, { taskStore: store, taskRequestedTtl }) {
  142. const task = await store.createTask({ ttl: taskRequestedTtl ?? 24 * 3600_000, pollInterval: 5000 })
  143. const started = Date.now()
  144. const timer = setInterval(() => {
  145. const pct = Math.min(99, Math.round(((Date.now() - started) / (seconds * 1000)) * 100))
  146. void taskStore.updateTaskStatus(task.taskId, 'working', `已完成 ${pct}%`).catch(() => clearInterval(timer))
  147. }, 5000)
  148. setTimeout(() => {
  149. clearInterval(timer)
  150. void taskStore.storeTaskResult(task.taskId, 'completed', {
  151. content: [
  152. { type: 'text', text: `${month} 销售报表已生成:订单 ${orders.size} 笔` },
  153. // 文件以嵌入资源返回:平台保存到会话工作区,不放进模型上下文(指南 §4.3)
  154. { type: 'resource', resource: { uri: `ref://reports/sales-${month}.csv`, mimeType: 'text/csv', text: ['id,customer,amount,status', ...[...orders.values()].map(o => `${o.id},${o.customer},${o.amount},${o.status}`)].join('\n') } },
  155. ],
  156. }).catch(() => {})
  157. }, seconds * 1000)
  158. // 给模型的即时说明:提交后马上返回
  159. return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `${month} 报表已开始导出,预计 ${seconds} 秒` } }
  160. },
  161. getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
  162. getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
  163. })
  164. server.experimental.tasks.registerToolTask('purge_cancelled', {
  165. title: '清理已取消订单',
  166. description: '永久删除所有已取消的订单。执行中会请用户确认(长任务中的询问)。',
  167. inputSchema: {},
  168. execution: { taskSupport: 'required' },
  169. annotations: { readOnlyHint: false, destructiveHint: true },
  170. }, {
  171. async createTask(_args, { taskStore: store, taskRequestedTtl }) {
  172. const task = await store.createTask({ ttl: taskRequestedTtl ?? 3600_000, pollInterval: 2000 })
  173. void (async () => {
  174. const victims = [...orders.values()].filter(o => o.status === 'cancelled').map(o => o.id)
  175. await taskStore.updateTaskStatus(task.taskId, 'input_required', '等待用户确认')
  176. let answer
  177. for await (const m of server.server.experimental.tasks.elicitInputStream(
  178. { mode: 'form', message: `将永久删除 ${victims.length} 个已取消订单:${victims.join('、') || '(无)'}。确认吗?`, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean', title: '确认删除' } }, required: ['confirm'] } },
  179. { relatedTask: { taskId: task.taskId } },
  180. )) if (m.type === 'result') answer = m.result
  181. const ok = answer?.action === 'accept' && answer.content?.confirm === true
  182. if (ok) for (const id of victims) orders.delete(id)
  183. await taskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: ok ? `已删除 ${victims.length} 个订单` : '用户没有确认,未删除' }] })
  184. })().catch(err => taskStore.storeTaskResult(task.taskId, 'failed', { isError: true, content: [{ type: 'text', text: String(err) }] }).catch(() => {}))
  185. return { task }
  186. },
  187. getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
  188. getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
  189. })
  190. // ---- 5. 工具列表变化(指南 §7) ----
  191. server.registerTool('enable_beta', {
  192. title: '开启测试功能',
  193. description: '开启测试功能后会多出一个 beta_forecast 工具(演示工具列表变化通知)。',
  194. inputSchema: {},
  195. annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
  196. }, async () => {
  197. betaEnabled = true
  198. for (const s of sessions.values()) s.addBeta()
  199. return { content: [{ type: 'text', text: '已开启,工具列表已更新' }] }
  200. })
  201. const addBeta = () => {
  202. if (server._registeredTools?.beta_forecast) return
  203. server.registerTool('beta_forecast', { title: '销量预测(测试)', description: '预测下个月的销量(测试功能)', inputSchema: {}, annotations: { readOnlyHint: true } },
  204. async () => ({ content: [{ type: 'text', text: '预计下月订单 120 笔' }] }))
  205. }
  206. if (betaEnabled) addBeta()
  207. // ---- 6. 资源与资源模板(指南 §8) ----
  208. server.registerResource('api-guide', 'ref://docs/order-api.md', {
  209. title: '订单系统接口说明', description: '订单状态的含义、退款规则', mimeType: 'text/markdown',
  210. }, async uri => ({
  211. contents: [{ uri: uri.href, mimeType: 'text/markdown', text: '# 订单状态\n- paid:已支付,未发货\n- shipped:已发货\n- cancelled:已取消\n\n# 退款规则\n发货前全额退款;发货后 7 天内可退。' }],
  212. }))
  213. server.registerResource('order', new ResourceTemplate('ref://orders/{orderId}', {
  214. list: async () => ({ resources: [...orders.values()].map(o => ({ uri: `ref://orders/${o.id}`, name: o.id, title: `订单 ${o.id}`, mimeType: 'application/json' })) }),
  215. }), { title: '订单详情', description: '单个订单的完整数据(JSON)', mimeType: 'application/json' }, async (uri, { orderId }) => {
  216. const o = orders.get(String(orderId))
  217. if (!o) throw new Error(`订单 ${orderId} 不存在`)
  218. return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ ...o, notes: notes.filter(n => n.orderId === o.id) }, null, 2) }] }
  219. })
  220. // ---- 7. 提示模板(指南 §8) ----
  221. server.registerPrompt('order_review', {
  222. title: '订单复盘',
  223. description: '针对一个订单写复盘:时间线、问题、改进建议',
  224. argsSchema: { orderId: z.string().describe('订单号'), focus: z.string().optional().describe('关注点(可选)') },
  225. }, async ({ orderId, focus }) => ({
  226. messages: [
  227. { role: 'user', content: { type: 'text', text: `请对订单 ${orderId} 做一次复盘,列出时间线、发现的问题和改进建议${focus ? `,重点关注:${focus}` : ''}。` } },
  228. { role: 'user', content: { type: 'resource', resource: { uri: `ref://orders/${orderId}`, mimeType: 'application/json', text: JSON.stringify(orders.get(orderId) ?? {}, null, 2) } } },
  229. ],
  230. }))
  231. return { server, addBeta }
  232. }
  233. // ---- HTTP:Streamable HTTP,有状态(询问用户需要在同一个 MCP 会话中收发) ----
  234. const sessions = new Map()
  235. const httpServer = http.createServer(async (req, res) => {
  236. const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
  237. if (url.pathname !== '/mcp') return res.writeHead(404).end()
  238. // 认证(指南 §9):每个请求都检查令牌
  239. if (req.headers.authorization !== `Bearer ${TOKEN}`) return res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unauthorized' }))
  240. let body = ''
  241. for await (const chunk of req) body += chunk
  242. const sid = req.headers['mcp-session-id']
  243. let s = sid ? sessions.get(sid) : undefined
  244. if (!s) {
  245. if (sid) return res.writeHead(404).end() // 会话已失效:客户端会重新初始化
  246. const { server, addBeta } = buildServer()
  247. const transport = new StreamableHTTPServerTransport({
  248. sessionIdGenerator: () => crypto.randomUUID(),
  249. onsessioninitialized: id => sessions.set(id, s),
  250. })
  251. transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId) }
  252. s = { server, transport, addBeta }
  253. await server.connect(transport)
  254. }
  255. await s.transport.handleRequest(req, res, body ? JSON.parse(body) : undefined)
  256. })
  257. httpServer.listen(PORT, HOST, () => console.log(`MCP 参考服务:http://${HOST}:${PORT}/mcp(Authorization: Bearer ${TOKEN === 'ref-secret' ? 'ref-secret' : '***'})`))