| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- // 参考 MCP 服务:按《MCP 接入指南》(docs/guides/mcp-guide.md)编写,演示 AgentServer 支持的各项 MCP 能力。
- // 业务是一个虚构的"订单系统",数据在内存中,重启后恢复初始状态。
- //
- // PORT=8790 REF_TOKEN=secret node server.mjs
- //
- // 在 AgentServer 后台"MCP 服务"中添加:远程 HTTP,地址 http://<主机>:8790/mcp,请求头 Authorization: Bearer secret
- import crypto from 'node:crypto'
- import http from 'node:http'
- import { z } from 'zod'
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
- import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
- const PORT = Number(process.env.PORT ?? 8790)
- const HOST = process.env.HOST ?? '127.0.0.1'
- const TOKEN = process.env.REF_TOKEN ?? 'ref-secret'
- // ---- 业务数据 ----
- const orders = new Map([
- ['A1001', { id: 'A1001', customer: '张三', amount: 1299, status: 'paid', items: ['储能电池 5kWh'] }],
- ['A1002', { id: 'A1002', customer: '李四', amount: 399, status: 'shipped', items: ['智能网关'] }],
- ])
- const notes = []
- /**
- * 长任务的存储:所有连接共用。
- * 指南 §5.3:任务要按认证身份归属,不要绑定在 MCP 会话上,否则平台重连(或重启)后查不到。
- * 这里只有一个令牌,所以直接共用;有多个调用方时,按令牌区分。
- */
- class SharedTaskStore extends InMemoryTaskStore {
- createTask(params, requestId, request) { return super.createTask(params, requestId, request, undefined) }
- getTask(taskId) { return super.getTask(taskId, undefined) }
- storeTaskResult(taskId, status, result) { return super.storeTaskResult(taskId, status, result, undefined) }
- getTaskResult(taskId) { return super.getTaskResult(taskId, undefined) }
- updateTaskStatus(taskId, status, message) { return super.updateTaskStatus(taskId, status, message, undefined) }
- listTasks(cursor) { return super.listTasks(cursor, undefined) }
- }
- const taskStore = new SharedTaskStore()
- const taskQueue = new InMemoryTaskMessageQueue()
- let betaEnabled = false
- function buildServer() {
- const server = new McpServer(
- { name: 'order-reference', version: '1.0.0' },
- {
- capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {}, list: {} } },
- taskStore,
- taskMessageQueue: taskQueue,
- // 写给模型看的服务说明:什么时候用哪个工具
- instructions: '订单系统。查订单用 get_order;导出报表很慢,用 export_report(长任务);删除类操作会要求确认。',
- },
- )
- // ---- 1. 只读工具 + 结构化输出(指南 §3、§4) ----
- server.registerTool('get_order', {
- title: '查询订单',
- description: '按订单号查询订单的客户、金额、状态和商品。订单号形如 A1001。',
- inputSchema: { orderId: z.string().regex(/^A\d{4}$/).describe('订单号,例如 A1001') },
- outputSchema: { id: z.string(), customer: z.string(), amount: z.number(), status: z.enum(['paid', 'shipped', 'cancelled']), items: z.array(z.string()) },
- annotations: { readOnlyHint: true, openWorldHint: false },
- }, async ({ orderId }) => {
- const o = orders.get(orderId)
- // 错误写给模型看:说清楚原因和下一步怎么做(指南 §3.4)
- if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在。可以先用 list_orders 查看现有订单号。` }] }
- // 结构化结果同时序列化成一段文本,兼容只看文本的客户端
- return { content: [{ type: 'text', text: JSON.stringify(o) }], structuredContent: o }
- })
- server.registerTool('list_orders', {
- title: '订单列表',
- description: '分页列出订单(每页最多 20 条),可以按状态筛选。',
- inputSchema: { status: z.enum(['paid', 'shipped', 'cancelled']).optional(), cursor: z.string().optional().describe('上一页返回的 nextCursor') },
- annotations: { readOnlyHint: true, openWorldHint: false },
- }, async ({ status, cursor }) => {
- const all = [...orders.values()].filter(o => !status || o.status === status)
- const start = cursor ? Number(cursor) : 0
- const page = all.slice(start, start + 20)
- const next = start + 20 < all.length ? String(start + 20) : null
- const structuredContent = { orders: page.map(o => ({ id: o.id, customer: o.customer, status: o.status })), nextCursor: next }
- return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }
- })
- // ---- 2. 写操作与提示(指南 §3.3) ----
- server.registerTool('add_note', {
- title: '添加备注',
- description: '给订单添加一条备注。每次调用都会新增一条(不幂等)。',
- inputSchema: { orderId: z.string(), text: z.string().max(500) },
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
- }, async ({ orderId, text }) => {
- notes.push({ orderId, text, at: new Date().toISOString() })
- return { content: [{ type: 'text', text: `已为 ${orderId} 添加备注(共 ${notes.filter(n => n.orderId === orderId).length} 条)` }] }
- })
- server.registerTool('cancel_order', {
- title: '取消订单',
- description: '取消订单。已取消的订单再次取消不会有变化(幂等)。这是破坏性操作,平台会要求用户确认。',
- inputSchema: { orderId: z.string(), reason: z.string().describe('取消原因') },
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
- }, async ({ orderId, reason }) => {
- const o = orders.get(orderId)
- if (!o) return { isError: true, content: [{ type: 'text', text: `订单 ${orderId} 不存在` }] }
- o.status = 'cancelled'
- return { content: [{ type: 'text', text: `订单 ${orderId} 已取消,原因:${reason}` }] }
- })
- // ---- 3. 调用过程中询问用户(指南 §6) ----
- server.registerTool('ship_order', {
- title: '安排发货',
- description: '为订单安排发货。会请用户填写收件信息。',
- inputSchema: { orderId: z.string() },
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
- }, async ({ orderId }) => {
- const r = await server.server.elicitInput({
- mode: 'form',
- message: `请填写订单 ${orderId} 的收件信息`,
- requestedSchema: {
- type: 'object',
- properties: {
- name: { type: 'string', title: '收件人', minLength: 1 },
- phone: { type: 'string', title: '手机号', minLength: 11, maxLength: 11 },
- address: { type: 'string', title: '地址' },
- speed: { type: 'string', title: '配送方式', oneOf: [{ const: 'standard', title: '普通' }, { const: 'express', title: '加急' }], default: 'standard' },
- insured: { type: 'boolean', title: '保价', default: false },
- },
- required: ['name', 'phone', 'address'],
- },
- })
- // 三种结果都要处理:提交、拒绝、关闭
- if (r.action === 'accept') return { content: [{ type: 'text', text: `已安排发货:${JSON.stringify(r.content)}` }] }
- if (r.action === 'decline') return { content: [{ type: 'text', text: '用户拒绝提供收件信息,未发货' }] }
- return { content: [{ type: 'text', text: '用户暂未处理,未发货。可以稍后再试' }] }
- })
- server.registerTool('connect_crm', {
- title: '连接 CRM',
- description: '连接外部 CRM。需要用户在浏览器中授权(网址模式),不在对话中索要密码。',
- inputSchema: {},
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
- }, async () => {
- const r = await server.server.elicitInput({ mode: 'url', message: '请在打开的页面中登录 CRM 并授权订单系统访问', url: 'https://crm.example.com/oauth/authorize?client=orders', elicitationId: crypto.randomUUID() })
- return { content: [{ type: 'text', text: r.action === 'accept' ? '用户已完成授权' : '用户没有授权' }] }
- })
- // ---- 4. 长任务(指南 §5) ----
- server.experimental.tasks.registerToolTask('export_report', {
- title: '导出销售报表',
- description: '导出指定月份的销售报表。很慢(演示中按 seconds 参数计时),以长任务方式执行,完成后平台会把结果发给你。',
- inputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/).describe('月份,例如 2026-10'), seconds: z.number().int().min(1).max(7200).default(30).describe('演示用:执行多少秒') },
- execution: { taskSupport: 'optional' },
- annotations: { readOnlyHint: true },
- }, {
- async createTask({ month, seconds }, { taskStore: store, taskRequestedTtl }) {
- const task = await store.createTask({ ttl: taskRequestedTtl ?? 24 * 3600_000, pollInterval: 5000 })
- const started = Date.now()
- const timer = setInterval(() => {
- const pct = Math.min(99, Math.round(((Date.now() - started) / (seconds * 1000)) * 100))
- void taskStore.updateTaskStatus(task.taskId, 'working', `已完成 ${pct}%`).catch(() => clearInterval(timer))
- }, 5000)
- setTimeout(() => {
- clearInterval(timer)
- void taskStore.storeTaskResult(task.taskId, 'completed', {
- content: [
- { type: 'text', text: `${month} 销售报表已生成:订单 ${orders.size} 笔` },
- // 文件以嵌入资源返回:平台保存到会话工作区,不放进模型上下文(指南 §4.3)
- { 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') } },
- ],
- }).catch(() => {})
- }, seconds * 1000)
- // 给模型的即时说明:提交后马上返回
- return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `${month} 报表已开始导出,预计 ${seconds} 秒` } }
- },
- getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
- getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
- })
- server.experimental.tasks.registerToolTask('purge_cancelled', {
- title: '清理已取消订单',
- description: '永久删除所有已取消的订单。执行中会请用户确认(长任务中的询问)。',
- inputSchema: {},
- execution: { taskSupport: 'required' },
- annotations: { readOnlyHint: false, destructiveHint: true },
- }, {
- async createTask(_args, { taskStore: store, taskRequestedTtl }) {
- const task = await store.createTask({ ttl: taskRequestedTtl ?? 3600_000, pollInterval: 2000 })
- void (async () => {
- const victims = [...orders.values()].filter(o => o.status === 'cancelled').map(o => o.id)
- await taskStore.updateTaskStatus(task.taskId, 'input_required', '等待用户确认')
- let answer
- for await (const m of server.server.experimental.tasks.elicitInputStream(
- { mode: 'form', message: `将永久删除 ${victims.length} 个已取消订单:${victims.join('、') || '(无)'}。确认吗?`, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean', title: '确认删除' } }, required: ['confirm'] } },
- { relatedTask: { taskId: task.taskId } },
- )) if (m.type === 'result') answer = m.result
- const ok = answer?.action === 'accept' && answer.content?.confirm === true
- if (ok) for (const id of victims) orders.delete(id)
- await taskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: ok ? `已删除 ${victims.length} 个订单` : '用户没有确认,未删除' }] })
- })().catch(err => taskStore.storeTaskResult(task.taskId, 'failed', { isError: true, content: [{ type: 'text', text: String(err) }] }).catch(() => {}))
- return { task }
- },
- getTask: async (_args, { taskId }) => taskStore.getTask(taskId),
- getTaskResult: async (_args, { taskId }) => taskStore.getTaskResult(taskId),
- })
- // ---- 5. 工具列表变化(指南 §7) ----
- server.registerTool('enable_beta', {
- title: '开启测试功能',
- description: '开启测试功能后会多出一个 beta_forecast 工具(演示工具列表变化通知)。',
- inputSchema: {},
- annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
- }, async () => {
- betaEnabled = true
- for (const s of sessions.values()) s.addBeta()
- return { content: [{ type: 'text', text: '已开启,工具列表已更新' }] }
- })
- const addBeta = () => {
- if (server._registeredTools?.beta_forecast) return
- server.registerTool('beta_forecast', { title: '销量预测(测试)', description: '预测下个月的销量(测试功能)', inputSchema: {}, annotations: { readOnlyHint: true } },
- async () => ({ content: [{ type: 'text', text: '预计下月订单 120 笔' }] }))
- }
- if (betaEnabled) addBeta()
- // ---- 6. 资源与资源模板(指南 §8) ----
- server.registerResource('api-guide', 'ref://docs/order-api.md', {
- title: '订单系统接口说明', description: '订单状态的含义、退款规则', mimeType: 'text/markdown',
- }, async uri => ({
- contents: [{ uri: uri.href, mimeType: 'text/markdown', text: '# 订单状态\n- paid:已支付,未发货\n- shipped:已发货\n- cancelled:已取消\n\n# 退款规则\n发货前全额退款;发货后 7 天内可退。' }],
- }))
- server.registerResource('order', new ResourceTemplate('ref://orders/{orderId}', {
- list: async () => ({ resources: [...orders.values()].map(o => ({ uri: `ref://orders/${o.id}`, name: o.id, title: `订单 ${o.id}`, mimeType: 'application/json' })) }),
- }), { title: '订单详情', description: '单个订单的完整数据(JSON)', mimeType: 'application/json' }, async (uri, { orderId }) => {
- const o = orders.get(String(orderId))
- if (!o) throw new Error(`订单 ${orderId} 不存在`)
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ ...o, notes: notes.filter(n => n.orderId === o.id) }, null, 2) }] }
- })
- // ---- 7. 提示模板(指南 §8) ----
- server.registerPrompt('order_review', {
- title: '订单复盘',
- description: '针对一个订单写复盘:时间线、问题、改进建议',
- argsSchema: { orderId: z.string().describe('订单号'), focus: z.string().optional().describe('关注点(可选)') },
- }, async ({ orderId, focus }) => ({
- messages: [
- { role: 'user', content: { type: 'text', text: `请对订单 ${orderId} 做一次复盘,列出时间线、发现的问题和改进建议${focus ? `,重点关注:${focus}` : ''}。` } },
- { role: 'user', content: { type: 'resource', resource: { uri: `ref://orders/${orderId}`, mimeType: 'application/json', text: JSON.stringify(orders.get(orderId) ?? {}, null, 2) } } },
- ],
- }))
- return { server, addBeta }
- }
- // ---- HTTP:Streamable HTTP,有状态(询问用户需要在同一个 MCP 会话中收发) ----
- const sessions = new Map()
- const httpServer = http.createServer(async (req, res) => {
- const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
- if (url.pathname !== '/mcp') return res.writeHead(404).end()
- // 认证(指南 §9):每个请求都检查令牌
- if (req.headers.authorization !== `Bearer ${TOKEN}`) return res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unauthorized' }))
- let body = ''
- for await (const chunk of req) body += chunk
- const sid = req.headers['mcp-session-id']
- let s = sid ? sessions.get(sid) : undefined
- if (!s) {
- if (sid) return res.writeHead(404).end() // 会话已失效:客户端会重新初始化
- const { server, addBeta } = buildServer()
- const transport = new StreamableHTTPServerTransport({
- sessionIdGenerator: () => crypto.randomUUID(),
- onsessioninitialized: id => sessions.set(id, s),
- })
- transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId) }
- s = { server, transport, addBeta }
- await server.connect(transport)
- }
- await s.transport.handleRequest(req, res, body ? JSON.parse(body) : undefined)
- })
- httpServer.listen(PORT, HOST, () => console.log(`MCP 参考服务:http://${HOST}:${PORT}/mcp(Authorization: Bearer ${TOKEN === 'ref-secret' ? 'ref-secret' : '***'})`))
|