内容
# 环法自行车赛 MCP 服务器
一个使用 [MCP 框架](https://github.com/QuantGeekDev/mcp-framework)构建的HTTP [模型上下文协议(Model Context Protocol)](https://modelcontextprotocol.io) 服务器,用于公开 **环法自行车赛赛事中心 API** (`https://racecenter.letour.fr`)作为 MCP 工具。
该 API 是公开且只读的;不需要身份验证。完整的端点合约位于 sibling OpenAPI 规范中,位于 `../tour-de-france/docs/openapi.yaml`。
## 文档
完整的文档站点(Fumadocs)位于 [`docs/`](docs) 并部署到 **https://tdf-docs.andru.codes** (S3 + CloudFront,由 [`infra-docs/`](infra-docs) 提供)。它涵盖了快速入门、约定和所有 22 个工具的参考。本地运行它:`cd docs && npm install && npm run dev`。
## 运行
```bash
npm install
npm run build
npm start # HTTP 流传输在 http://127.0.0.1:8080/mcp
```
传输配置在 [`src/index.ts`](src/index.ts) 中(`type: "http-stream"`,端口 `8080`,端点 `/mcp`,CORS `*`)。
### 快速测试
```bash
# 1) 初始化(获取 Mcp-Session-Id 响应头)
curl -si http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
# 2) 调用工具(用步骤 1 中的会话 ID 替换 <SID>)
curl -s http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: <SID>' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tdf_stages","arguments":{"year":2026}}}'
```
## 工具(22 个)
所有工具都以 `tdf_` 为前缀,每个描述都命名为“环法自行车赛”,因此即使客户端将服务器显示为不透明的 ID,它们也能保持自我识别。
常见参数:`year`(默认 `2026`),`stage`(1–21,默认 `1`;`0` = 比赛前/一般排名),`lang`(`en`/`fr`/`es`/`de`,默认 `en`)。实时/结果工具在比赛开始前返回 `[]`。
响应 **默认紧凑**:后端记账键(`_bind`、`_origin`、`_parent`、`_virtual`、`_updatedAt`、`_gets`)被剥离(`_id`/`_key` 作为连接键保留)。所有列表工具接受 `limit` / `offset` 进行分页(`tdf_event` 和 `tdf_active_checkpoint` 是单对象,不接受)。
| 工具 | 额外参数 | 默认大小 | 备注 |
| --- | --- | --- | --- |
| `tdf_stages` | `stage?`,`full?`,`raw?` | ~12 KB(曾 ~164 KB) | 按阶段排序;除 `full:true` 外,删除每个城市的法语 HTML;`stage:N` → 一个阶段(~0.5 KB) |
| `tdf_riders` | `bib?`,`team?`,`full?`,`raw?` | ~51 KB(曾 ~236 KB) | 184 名骑手(团队过滤掉),按 Bib 排序;除 `full:true` 外,删除图像 URL;解析每个骑手的 `team`/`teamName`;用 `bib:N` / `team:"UEX"` 过滤 |
传递 `full:true` 以获取完整有效载荷,或 `raw:true` 以保留后端 `_` 元数据(以及骑手的 opaque `$team` 哈希而不是解析后的代码)。
| 工具 | 参数 | 端点 |
| --- | --- | --- |
| `tdf_event` | – | `/api/event` |
| `tdf_social` | limit, offset | `/api/social` |
| `tdf_ads` | limit, offset | `/api/ad` |
| `tdf_stages` | year, stage?, full?, raw?, +page | `/api/stage-{year}` |
| `tdf_teams` | year, +page | `/api/team-{year}` |
| `tdf_riders` | year, bib?, team?, full?, raw?, +page | `/api/allCompetitors-{year}` |
| `tdf_rider_telemetry` | year, +page | `/api/telemetryCompetitor-{year}` |
| `tdf_rankings` | year, stage, +page | `/api/rankingType-{year}-{stage}` |
| `tdf_stage_results` | year, stage, +page | `/api/rankingTypeArrival-{year}-{stage}` |
| `tdf_jersey_standings` | year, stage, +page | `/api/rankingTypeJerseys-{year}-{stage}` |
| `tdf_time_trial_rankings` | year, stage, +page | `/api/rankingTypeTrial-{year}-{stage}` |
| `tdf_rankings_widget` | year, stage, +page | `/api/rankingTypeWidget-{year}-{stage}` |
| `tdf_checkpoints` | year, stage, +page | `/api/checkpointList-{year}-{stage}` |
| `tdf_active_checkpoint` | year, stage | `/api/checkpoint-{year}-{stage}` |
| `tdf_race_groups` | year, stage, +page | `/api/pack-{year}-{stage}` |
| `tdf_group_telemetry` | year, stage, +page | `/api/telemetryPack-{year}-{stage}` |
| `tdf_live_commentary` | year, stage, +page | `/api/flashInfoLive-{year}-{stage}` |
| `tdf_withdrawals` | year, stage, +page | `/api/stageWithdrawals-{year}-{stage}` |
| `tdf_fantasy` | year, stage, +page | `/api/fantasy-{year}-{stage}` |
| `tdf_insights` | year, stage, +page | `/api/insights-{year}-{stage}` |
| `tdf_convoy_vehicles` | year, stage, +page | `/api/extraVehicle-{year}-{stage}` |
| `tdf_stage_article` | lang, year, stage, +page | `/api/publication_{lang}-{year}-{stage}` |
*(+page = 可选 `limit` / `offset`。)*
## 项目结构
```
src/
├── apiClient.ts # 共享获取包装器(基本 URL,204 -> [])
├── tools/ # 每个端点一个 MCPTool(22 个文件)
└── index.ts # MCPServer + http-stream 传输
```
## 添加工具
```bash
npx mcp-framework add tool my-tool
```
每个工具遵循以下形状(注意:**Zod v3** — 见下文):
```typescript
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { apiGet } from "../apiClient.js";
const schema = z.object({
year: z.number().int().default(2026).describe("四位年份"),
});
type Input = z.infer<typeof schema>;
class GetStagesTool extends MCPTool<Input, typeof schema> {
name = "tdf_stages";
description = "某届环法自行车赛的阶段列表";
schema = schema;
async execute({ year }: Input) {
return apiGet(`/api/stage-${year}`);
}
}
export default GetStagesTool;
```
## 备注 / 注意事项
- **Zod 锁定到 `3.x`。** `mcp-framework@0.2.22` 将 `zod: 3.x` 声明为对等依赖,其类型与 Zod v4 不兼容(`ZodObject<..., $strip>` 不匹配)。如果您看到 `Property 'schema' ... is not assignable`,请检查 `node_modules/zod` 是 3.x 版本。
- `tsconfig` 使用 `module`/`moduleResolution: NodeNext`,因此相对导入使用显式 `.js` 扩展名(`../apiClient.js`)。
- 该服务器与 **非官方** ASO 源交互,通过站点反向工程而来。它可能会在没有通知的情况下更改 — 请保持请求速率温和。
- **编码 / mojibake.** 上游 API 是 UTF-8(`charset=utf-8`),而该服务器在传输中发出 UTF-8 — 已验证:`é` 是字节 `c3 a9`,例如“Kévin Vauquelin”在 `tools/call` 中正确循环通过。如果客户端为法语口音显示 `é`/``,则客户端以 latin1/cp1252 解码 UTF-8,而不是服务器错误。注意 HTTP 流传输发送 `Content-Type: text/event-stream` 而没有显式 `charset`(SSE 按规范为 UTF-8);一些客户端仍然猜测错误。保持响应紧凑(上面)也避免了“有效载荷太大 → 溢出到文件 → 重新编码”的路径,该路径通常会引入 mojibake。
## 与 MCP 客户端(stdio 替代品)一起使用
该服务器通过 HTTP 运行。对于基于 stdio 的客户端(例如 Claude Desktop),请指向构建的入口文件:
```json
{
"mcpServers": {
"tour-de-france": { "command": "node", "args": ["/absolute/path/to/tour-de-france-mcp/dist/index.js"] }
}
}
```
(stdio 需要将 `src/index.ts` 切换回默认 stdio 传输,或运行单独的构建 — 上面的 HTTP 传输是该项目的默认传输。)