1. 节假日抢票场景下的真实痛点与 12306-MCP 能做什么
节假日抢票这件事,最折磨人的不是手速,而是信息差。你打开 12306 官方 App,输入出发地、目的地、日期,点查询,翻列表,看余票,退出来再换一个车次组合重新查一遍。整个过程机械、重复,而且一旦你想同时盯三四个备选方案,手指和眼睛都不够用。更别说有些车次是整点放票、有些是分批放票,你根本不可能一直守在手机前面。
我试过在国庆前一周帮家里人查武汉到广州的票,光是「下午 17:30 之前有哪些车次还有二等座」这个问题,就在 App 里来回翻了十几分钟。后来我换了个思路:能不能让程序帮我查,我只负责看结果?这就是 12306-MCP 要解决的问题。
先说清楚三个概念,方便后面跟做。
12306-MCP 是什么:MCP 全称 Model Context Protocol,你可以把它理解成「给大模型用的标准工具接口」。12306-MCP 就是把 12306 的查询能力(车站代码、余票、车次时刻)封装成一组标准工具,让支持 MCP 的智能体可以直接调用。它本身不卖票、不抢票,只做查询,所以用它来盯余票、做提醒是完全合规的。
通义灵码 2.5 在这里扮演什么角色:它是一个编程智能体,能读 MCP 的工具描述、生成调用代码、帮你把 Node.js 服务搭起来。你不需要从零手写 HTTP 转发和 CORS 处理,把需求描述清楚,它给你可运行的骨架。
qwen3 负责什么:负责把「今天下午 17:30 之前武汉到广州的火车票」这种自然语言,翻译成对 12306-MCP 工具的结构化调用参数。没有它,你得自己拼 JSON;有了它,你说话就行。
适合谁:会一点 Node.js、想在节假日给自己或家人做一个余票查询小工具的人。不需要你会写 MCP 协议,但需要你能跑node命令、能看懂 JSON 配置。
这一篇我会交付四样东西:可复制的 MCP 配置片段、Node.js 启动脚本、TaoToken 统一 Key 的接入步骤、以及本地验证查询结果的完整动作。你照着做,最后能在浏览器里输入自然语言,拿到车次和余票。
2. TaoToken 前置准备:统一 Key 接入 12306-MCP 与 qwen3
在动手写代码之前,先把「钥匙」准备好。这里我用 TaoToken 做统一接入,原因是它把模型调用和 MCP 工具调用的鉴权收敛到一个 Key 上,省得你在通义灵码、本地脚本、代理服务之间来回换 token。
先明确一个边界:TaoToken 在这里的角色是「统一的 API 接入层」,你通过它拿到模型对话能力和工具调用能力,12306-MCP 本身是独立的查询服务,两者通过配置组合在一起。不要把 TaoToken 理解成某种特殊通道,它就是一个标准的 API 入口。
第一步,拿到 API Key。
访问 API Keys 管理页面:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite登录后创建一个新 Key,复制出来。这个 Key 后面会同时用在两处:一处是通义灵码里配置模型服务,一处是本地 Node.js 脚本调用 qwen3。
第二步,确认 Base URL。
TaoToken 的 API 入口是:
https://taotoken.net/api注意这里不加 UTM 参数,保持干净。你在配置里填的 Base URL 就是它,后面拼/v1/chat/completions这类路径由 SDK 或脚本自己处理。
第三步,确认 Model ID。
qwen3 系列在 TaoToken 上的模型标识,建议你先在模型对话页面确认当前可用的准确名称:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite在对话页面选择 qwen3,看它实际调用的模型 ID 是什么,把它记下来。常见的形式是qwen3或带版本号的变体。Model ID 必须和平台实际暴露的一致,写错了会直接报模型不存在。
到这里你手上有三样东西:
| 项目 | 值 | 用途 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有请求的根地址 |
| API Key | 你创建的那串 | 鉴权 |
| Model ID | 平台确认的 qwen3 标识 | 指定模型 |
第四步,理解 12306-MCP 的接入位置。
12306-MCP 不是通过 TaoToken 调用的,它是本地或远程的一个 MCP 服务。你的架构是这样的:
浏览器页面 ↓ 本地 Node.js 代理(proxy-server.js) ↓ 12306-MCP 服务(查询车次/余票) ↑ qwen3(通过 TaoToken 调用,负责理解自然语言并决定调哪个工具)也就是说,TaoToken 负责「大脑」的调用,12306-MCP 负责「手脚」的执行。两者通过你的 Node.js 服务串起来。
第五步,环境检查。
在开始写配置之前,确认本机 Node.js 版本。打开终端:
node -v npm -v建议 Node.js 18 以上,因为后面用到的fetch和部分异步写法在低版本上要额外 polyfill。如果版本太低,先去 Node.js 官网装一个 LTS 版本。
如果你打算长期做编码和 Agent 类项目,可以了解一下 Coding Plan,它更适合这种需要反复调试、多轮对话的场景:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite前置准备到这里就齐了。接下来进入可复制的配置环节。
3. 可复制配置:MCP 配置片段、Node.js 启动脚本与 settings 文件
这一节是全文最核心的部分,所有片段都可以直接复制。我按「MCP 配置 → 项目结构 → 启动脚本 → 模型配置」的顺序来。
3.1 12306-MCP 的 MCP 配置片段
在通义灵码或支持 MCP 的客户端里,MCP 服务通常用一个 JSON 描述。下面是一个标准的 12306-MCP 配置片段,路径和字段名保持通用写法:
{ "mcpServers": { "12306-mcp": { "command": "npx", "args": [ "-y", "12306-mcp" ], "env": { "MCP_LOG_LEVEL": "info" } } } }如果你用的是已经部署好的远程 MCP 服务,把command/args换成 URL 形式:
{ "mcpServers": { "12306-mcp": { "url": "http://localhost:8080/mcp", "transport": "http" } } }注意:
command和url两种写法不要混用。本地用npx拉起,远程用url。端口号按你实际部署的来,不要照抄 8080。
3.2 项目结构
在本地建一个目录,比如12306tray,结构如下:
12306tray/ ├── 12306-mcp-client.js # 前端 JS 客户端逻辑 ├── index.html # 测试页面 ├── server.js # 主 HTTP 服务器 ├── proxy-server.js # 代理服务器,转发到 MCP ├── check-environment.js # 环境检测 ├── start-ticket-assistant.js# 一键启动脚本 ├── config.js # 配置文件 ├── package.json └── README.md3.3 config.js:把 Key 和地址集中管理
// config.js module.exports = { // TaoToken 统一接入 taoToken: { baseUrl: 'https://taotoken.net/api', apiKey: process.env.TAOTOKEN_API_KEY || '你的_API_KEY', model: 'qwen3' }, // 本地服务端口 server: { mainPort: 3000, proxyPort: 3001 }, // 12306-MCP 服务地址 mcp: { baseUrl: 'http://localhost:8080/mcp' } };把 API Key 放在环境变量里更安全,启动前执行:
export TAOTOKEN_API_KEY=你的KeyWindows 用set TAOTOKEN_API_KEY=你的Key。
3.4 proxy-server.js:解决 CORS 并转发请求
// proxy-server.js const http = require('http'); const config = require('./config'); const PROXY_PORT = config.server.proxyPort; const MCP_BASE = config.mcp.baseUrl; async function readRequestBody(req) { let data = ''; for await (const chunk of req) data += chunk; return data; } const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } try { const body = await readRequestBody(req); const targetUrl = MCP_BASE + req.url; const upstream = await fetch(targetUrl, { method: req.method, headers: { 'Content-Type': 'application/json' }, body: req.method === 'POST' ? body : undefined }); const result = await upstream.text(); res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); res.end(result); } catch (err) { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); } }); server.listen(PROXY_PORT, () => { console.log(`代理服务器已启动: http://localhost:${PROXY_PORT}`); });3.5 server.js:主服务器
// server.js const http = require('http'); const fs = require('fs'); const path = require('path'); const config = require('./config'); const PORT = config.server.mainPort; const server = http.createServer((req, res) => { let filePath = req.url === '/' ? '/index.html' : req.url; filePath = path.join(__dirname, filePath); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/html' }); res.end('<h1>404 Not Found</h1>'); return; } const ext = path.extname(filePath); const type = ext === '.html' ? 'text/html' : 'application/javascript'; res.writeHead(200, { 'Content-Type': type }); res.end(data); }); }); server.listen(PORT, () => { console.log(`主服务器已启动: http://localhost:${PORT}`); });3.6 start-ticket-assistant.js:一键启动
// start-ticket-assistant.js const { spawn } = require('child_process'); function run(script) { const child = spawn('node', [script], { stdio: 'inherit' }); child.on('error', (err) => console.error(`${script} 启动失败:`, err)); return child; } console.log('正在启动 12306 火车票智能查询助手...'); run('server.js'); run('proxy-server.js');3.7 通义灵码侧的 settings 配置
如果你在通义灵码里配置模型服务,对应的 settings 片段如下(字段名按客户端实际要求调整):
{ "model.provider": "openai-compatible", "model.baseUrl": "https://taotoken.net/api", "model.apiKey": "你的_API_KEY", "model.modelId": "qwen3", "mcp.servers": ["12306-mcp"] }注意:Base URL、API Key、Model ID 这三件套必须同时正确。少一个都会在调用时报错,后面第 5 节会逐个对照真实报错。
配置到这里就完整了。接下来验证它到底能不能跑通。
4. 验证请求:从启动服务到拿到武汉到广州的真实车次结果
配置写完不代表能用,必须一步步验证。这一节我按「启动 → 单接口测试 → 自然语言查询」的顺序走,每一步都给出预期结果。
4.1 启动服务
在项目目录下执行:
node start-ticket-assistant.js预期输出:
正在启动 12306 火车票智能查询助手... 主服务器已启动: http://localhost:3000 代理服务器已启动: http://localhost:3001如果只看到一个服务起来,说明另一个端口被占用了,去第 5 节看端口冲突的排查。
4.2 验证环境检测脚本
node check-environment.js这个脚本会打印 Node.js 版本和当前配置。确认baseUrl是https://taotoken.net/api,model是qwen3。
4.3 单接口验证:查车站代码
先不碰大模型,直接测代理转发是否通。用 curl:
curl -X POST http://localhost:3001/api/station-code-of-citys \ -H "Content-Type: application/json" \ -d '{"citys": ["武汉", "广州"]}'预期返回类似:
{ "武汉": "WHN", "广州": "GZQ" }如果这一步就失败,说明代理到 12306-MCP 的链路有问题,先别往下走。
4.4 单接口验证:查余票
curl -X POST http://localhost:3001/api/tickets \ -H "Content-Type: application/json" \ -d '{ "from": "WHN", "to": "GZQ", "date": "2025-10-01" }'预期返回一个车次数组,每个元素包含车次号、出发时间、到达时间、各席别余票。日期换成你实际要查的。
4.5 自然语言查询:qwen3 调用 MCP
打开浏览器访问:
http://localhost:3000在输入框里输入:
请帮我查询一下今天下午17:30之前武汉到广州的火车票预期行为:页面把这句话发给本地服务,本地服务调用 TaoToken 的 qwen3,qwen3 解析出「出发地=武汉、目的地=广州、时间上限=17:30」,然后决定调用 12306-MCP 的station-code-of-citys和tickets两个工具,拿到结果后组织成自然语言返回。
预期结果类似:
17:30 之前武汉到广州有以下车次: G1001 08:00-12:30 二等座有票 G1003 09:15-13:50 二等座有票 G1005 10:40-15:10 二等座候补 ...4.6 验证成功的判断标准
三个条件同时满足才算跑通:
第一,浏览器控制台没有 CORS 报错。第二,Network 面板里能看到对localhost:3001的请求返回 200。第三,页面显示的车次信息和你手动在 12306 查到的对得上。
如果车次对不上,先检查日期格式和车站代码是否正确。车站代码用错是最常见的「查得到但结果不对」的原因。
4.7 把查询结果做成提醒
验证通过后,你可以在12306-mcp-client.js里加一个定时器,每隔几分钟查一次,发现目标车次余票从「无」变「有」就打印提醒:
setInterval(async () => { const result = await queryTickets('WHN', 'GZQ', '2025-10-01'); const target = result.find(t => t.trainNo === 'G1001'); if (target && target.seats['二等座'] !== '无') { console.log('有票了:', target.trainNo, target.seats['二等座']); } }, 5 * 60 * 1000);这就是「余票提醒」的最小实现。不需要复杂框架,一个定时器加一次查询就够。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 逐个对照
这一节把我在调试过程中真实遇到的报错列出来,每个都给出原因和修法。你遇到问题时直接对照。
5.1 401 Unauthorized
现象:调用 qwen3 时返回 401,或者浏览器 Network 里看到401。
原因:API Key 没填、填错、或者环境变量没生效。
排查步骤:
先确认环境变量:
echo $TAOTOKEN_API_KEY如果输出为空,说明没设置成功。重新执行export,或者直接在config.js里临时写死测试。
再确认 Key 没有多余空格。从网页复制时经常带上换行或空格,用trim()处理:
apiKey: (process.env.TAOTOKEN_API_KEY || '').trim()最后确认 Base URL 是https://taotoken.net/api,不要多写/v1或少写/api。
5.2 local proxy failed
现象:前端报local proxy failed或Failed to fetch。
原因:代理服务器没启动,或者端口不对。
排查步骤:
先看代理是否在跑:
netstat -ano | findstr :3001Windows 用上面这条,macOS/Linux 用:
lsof -i :3001如果没有输出,说明proxy-server.js没起来。单独启动它看报错:
node proxy-server.js如果报EADDRINUSE,就是端口被占用,去 5.3。
5.3 端口冲突 EADDRINUSE
现象:Error: listen EADDRINUSE: address already in use :::3000。
原因:3000 或 3001 被别的程序占了。
排查步骤:
netstat -ano | findstr :3000找到最后一列的 PID,然后:
taskkill /PID <pid> /FmacOS/Linux:
kill -9 <pid>或者干脆改config.js里的端口号,换成 6000/6001 之类不常用的。
5.4 reading 'choices' of undefined
现象:TypeError: Cannot read properties of undefined (reading 'choices')。
原因:模型返回结构和你代码里取值的路径不一致。通常是请求根本没成功,返回的是错误对象,但代码直接去取response.choices。
修法:在取值前先判断:
const data = await res.json(); if (!data.choices || !data.choices[0]) { console.error('模型返回异常:', JSON.stringify(data)); return; } const content = data.choices[0].message.content;同时检查 Model ID 是否写对。Model ID 错误时,有些网关会返回错误结构而不是标准 choices。
5.5 OAuth 相关报错
现象:出现OAuth、token expired、invalid_grant之类字样。
原因:如果你用的是需要 OAuth 流程的客户端(比如某些 IDE 插件),token 过期或授权范围不对。
修法:重新走一遍授权流程,或者在客户端里重新登录。如果你用的是 API Key 方式(本篇的方式),一般不会碰到 OAuth,出现这个报错说明客户端配置成了 OAuth 模式,改回 API Key 模式即可。
5.6 三件套检查清单
出现任何调用失败,先按这个清单过一遍:
| 检查项 | 正确值 | 常见错误 |
|---|---|---|
| Base URL | https://taotoken.net/api | 多写/v1、少写/api |
| API Key | 创建的那串,无空格 | 复制带换行、用了旧 Key |
| Model ID | 平台确认的 qwen3 标识 | 写成qwen-3、qwen3.5等不存在名称 |
这三项任意一项错,都会导致调用失败。先把它们对齐,再去看别的。
5.7 CORS 仍然报错
如果代理已经设置了Access-Control-Allow-Origin但浏览器还是拦,检查是不是请求直接打到了 12306-MCP 而不是代理。前端所有请求都应该指向localhost:3001,不要指向 MCP 的原始地址。
6. 把查询助手用起来:接入文档、模型对话与长期编码的选择
走到这里,你应该已经能在浏览器里输入自然语言,拿到武汉到广州的车次和余票了。剩下的就是把它用起来,以及后续怎么扩展。
如果你在接入过程中卡住,优先看接入文档,里面有针对不同客户端的配置示例:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite如果你想先验证 qwen3 对自然语言的理解能力,不写代码,直接在模型对话页面测试「今天下午 17:30 之前武汉到广州的火车票」这类问法,看它能不能正确解析出参数:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite如果你打算把这个助手做成长期运行的服务,比如节假日期间一直挂着盯票,或者继续加功能(多城市监控、微信推送、Docker 部署),Coding Plan 更适合这种持续迭代的场景:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite如果你需要管理多个 Key 或查看调用量,控制台在这里:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite最后说一个实际经验:节假日查询频率不要设太高,几分钟一次足够。查得太频繁,一方面没必要,另一方面 12306 的接口也有自己的节奏。把定时器设成 5 分钟,配合「只在余票状态变化时提醒」的逻辑,既省资源又不会漏掉放票。
代码跑通之后,你会发现真正有价值的不是「查一次票」,而是「持续盯着,有变化就告诉你」。这才是这个助手相比手动刷 App 的核心优势。