内容
# calendar-ics-mcp
[](LICENSE)
[](https://hub.docker.com/r/hromadkom/calendar-ics-mcp)
[](https://hub.docker.com/r/hromadkom/calendar-ics-mcp)
一款只读 [MCP](https://modelcontextprotocol.io)(模型上下文协议)服务器,它将Outlook/Exchange **发布的ICS日历订阅源**转换为三个小型JSON工具,AI助手可以直接使用——“今天我的日历上有什么?”,从真实的订阅源中回答,正确处理重复事件、覆盖、时区和夏令时。
用Rust编写,占用空间极小:整个镜像约为4 MB(`FROM scratch`,musl-static,无异步运行时),服务器空闲时约为3 MB RSS。
## 为什么存在
已发布的ICS URL是给予工具读取日历的最简单方式:**无需OAuth,无需租户权限,无需应用注册**——URL本身就是唯一的凭证。这使其在官方Microsoft 365连接器不可用或不被允许的情况下非常适用。
但是,不能直接将通用Web获取工具指向订阅源:
- **订阅源会被截断。** 助手获取工具限制响应大小(Claude在大约66 kB时截断),静默丢弃超过限制的每个事件——而日历订阅源大多是样板,因此限制很快就会生效。该服务器下载完整的订阅源并验证其完整性。
- **ICS不是事件列表。** 每周会议是一个`VEVENT`加上一个`RRULE`;已取消的实例是一个`EXDATE`;更改为星期四的实例是一个单独的`RECURRENCE-ID`组件,必须覆盖它来自的时隙。正确获取“今日会议”意味着在正确的时区、跨越夏令时边界扩展所有这些。
该服务器执行此扩展,并返回小型、即用的JSON。
## 工具
| 工具 | 输入 | 输出 |
| --- | --- | --- |
| `get_events` | `{ date?: "YYYY-MM-DD" }` — 默认:`TZ_DEFAULT`中的今天 | 与当天重叠的事件,按开始时间排序 |
| `get_events_range` | `{ from, to }`(包含,最大31天) | 在日期范围内相同格式 |
| `feed_info` | — | 订阅源诊断 — 参见下文 |
每个工具都以`structuredContent`和完全相同的JSON序列化到`content[0].text`返回其有效载荷。域故障(订阅源无法访问、订阅源被截断、参数错误)以正常的工具结果返回,`isError: true`,而非JSON-RPC错误。
### `get_events` / `get_events_range`
```json
{
"date": "2026-07-14",
"timezone": "Europe/Prague",
"events": [
{
"summary": "设计审查",
"start": "2026-07-14T14:15:00+02:00",
"end": "2026-07-14T14:45:00+02:00",
"all_day": false,
"location": "Microsoft Teams Meeting",
"busy_status": "TENTATIVE",
"is_recurring": true,
"meeting_url": "https://teams.microsoft.com/l/meetup-join/…",
"description": "议程不含Teams拨号器样板"
}
]
}
```
定时事件使用ISO 8601,偏移量为`TZ_DEFAULT`;全天事件使用仅日期字符串,结束日期为**排除**。`get_events_range`返回相同的信封,其中`from`和`to`取代`date`。
- `busy_status`来自`X-MICROSOFT-CDO-BUSYSTATUS`(`BUSY` / `TENTATIVE` / `FREE` / `OOF`;默认为`BUSY`)。
- `meeting_url`是`X-MICROSOFT-SKYPETEAMSMEETINGURL`属性、描述或位置中找到的第一个Teams/Zoom/Meet链接。
- `description`剥离了Teams样板(从第一个`____…`分隔符开始),并限制在~2000个字符内。
- 一天的查询返回与当天重叠的每个事件:一个会议在午夜后继续显示在两天内,多日请假在每一天都显示。
### `feed_info`
诊断 — 调用此函数首先检查是否有问题。
```json
{
"feed_bytes": 148213,
"vevent_count": 312,
"ends_with_end_vcalendar": true,
"dtstart_min": "2024-01-08T07:00:00.000Z",
"dtstart_max": "2027-06-30T15:30:00.000Z",
"last_fetch_at": "2026-07-14T09:12:03.000Z",
"cache_age_seconds": 12,
"source_host": "https://outlook.office365.com/…"
}
```
`source_host`是订阅源URL屏蔽到其来源 — 绝密路径从未被包含。`ends_with_end_vcalendar: false`表示快照到达时被截断;`feed_info`仍然回答(即诊断),但事件工具将拒绝提供部分数据。
## 获取您的ICS URL
在网页版Outlook中:**设置 → 日历 → 共享日历 → 发布日历**。选择日历,选择**“可以查看所有详细信息”**(较低权限会剥夺这些工具返回的字段),点击**发布**,并复制**ICS**链接 — 而不是HTML链接。
> [!WARNING]
> 将该URL视为密码。它是一种无身份验证的凭证:任何拥有它的人都可以读取整个日历,它不会过期,并且唯一撤销它的方法是取消发布日历 — 这会使其对所有人无效。永远不要提交它,记录它或将其粘贴到问题中。参见 [SECURITY.md](SECURITY.md)。
## 快速入门
先决条件:Docker。镜像已发布为`linux/amd64`和`linux/arm64`。
```bash
docker pull hromadkom/calendar-ics-mcp:latest
```
然后将其添加到您的MCP客户端。对于Claude Desktop,在`claude_desktop_config.json`中:
```json
{
"mcpServers": {
"calendar-ics": {
"command": "docker",
"args": ["run", "-i", "--rm", "--init", "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "-e", "ICS_URL", "-e", "TZ_DEFAULT", "hromadkom/calendar-ics-mcp:latest"],
"env": {
"ICS_URL": "https://outlook.office365.com/owa/calendar/…/reachcalendar.ics",
"TZ_DEFAULT": "Europe/Prague"
}
}
}
}
```
注意:
- 无值`-e ICS_URL`从`docker` CLI环境(由客户端从`env`块填充)复制变量到容器 — URL在进程参数或`docker ps`中从未出现。
- Claude Desktop使用最小的`PATH`生成命令;如果找不到`docker`,请使用绝对路径(例如`/usr/local/bin/docker`)。
- 优先使用版本标签(`:0.3.0`)而不是`:latest`。
### 无Docker
没有预编译的二进制文件,dockerized构建生成一个**Linux musl**二进制文件 — 它不能在macOS或Windows上运行。对于本机二进制文件,您需要主机Rust工具链(参见`rust-toolchain.toml`获取固定版本):
```bash
cargo build --release # target/release/calendar-ics-mcp
```
将客户端的`command`指向该路径,并带有相同的`env`块。如果您**在Linux**上并且想要来自容器构建的静态musl二进制文件:
```bash
docker compose run --rm dev cargo build --release
docker compose run --rm dev cp target/release/calendar-ics-mcp ./calendar-ics-mcp
```
(`/app/target`是一个命名卷,因此二进制文件必须复制到绑定安装的工作目录才能到达主机。它被gitignore了。)
## HTTP模式
当设置`HTTP_BIND`时,同一个二进制文件提供 **MCP Streamable HTTP** — 无状态`POST /mcp`,以及用于容器健康检查的`GET /healthz`。这是为长期运行的代理而非桌面客户端设计的。没有会话和SSE流;`GET /mcp`返回405。
```bash
cp .env.example .env # 填写ICS_URL
docker compose up -d calendar-ics-http # 监听 127.0.0.1:8590
curl -s http://127.0.0.1:8590/healthz
```
由于scratch镜像没有shell,二进制文件会为健康检查自行探测:`/calendar-ics-mcp --health`。
> [!IMPORTANT]
> 此模式适用于受信任的网络 — 一个compose网络,一个环回绑定,或一个VM本地端口。`HTTP_BEARER_TOKEN` **默认情况下未设置**,如果它未设置,任何可以访问端口的人都可以读取您的日历。设置它,并在端口可从主机访问时,在前面添加一个TLS终止的反向代理 — 服务器讲纯HTTP。
## 配置
所有配置都通过环境变量进行。
| 变量 | 必需 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `ICS_URL` | 是 | — | 已发布的ICS订阅源URL(`http`/`https`; 使用`https`)。作为凭证使用 — 永远不要记录或提交它。 |
| `TZ_DEFAULT` | 否 | `Europe/Prague` | 用于解释日期查询和格式化输出的IANA时区。 |
| `CACHE_TTL_SECONDS` | 否 | `300` | 下载的订阅源的内存缓存TTL。 |
| `FETCH_TIMEOUT_MS` | 否 | `15000` | 订阅源下载超时。 |
| `HTTP_BIND` | 否 | —(stdio) | `<ip>:<port>` — 提供MCP Streamable HTTP而不是stdio,并使用`GET /healthz`。 |
| `HTTP_BEARER_TOKEN` | 否 | — | 在`POST /mcp`上需要`Authorization: Bearer …`(视为机密)。 |
主机自己的`TZ`被刻意忽略 — 结果仅取决于`TZ_DEFAULT`和订阅源,因此相同的查询在任何机器上都会给出相同的答案。
## 架构
```
src/
├── main.rs # 入口点:配置、信号、传输选择、--health探测
├── lib.rs # 模块连接(测试目标的入口点)
├── mcp.rs # 手动JSON-RPC 2.0 / MCP协议层(无SDK,无tokio)
├── http.rs # Streamable HTTP传输:POST /mcp,GET /healthz
├── server.rs # 工具声明 + 处理程序(get_events / get_events_range / feed_info)
├── config.rs # 环境变量解析 + 验证(单一环境变量读取位置)
├── logger.rs # 仅stderr的JSON记录器(stdout保留用于JSON-RPC)
├── errors.rs # 类型错误 + URL屏蔽/净化助手
├── feed.rs # ureq获取 + 单槽TTL缓存 + END:VCALENDAR完整性检查
└── ics/
├── mod.rs # 子模块连接
├── model.rs # 解析日历类型
├── tzids.rs # 预解析Windows→IANA时区ID重写(确定性时区)
├── parse.rs # 手动ICS内容行解析器
├── rrule_slots.rs# rrule crate作为裸槽生成器(EXDATE保留我们自己的)
├── expand.rs # RRULE/EXDATE/重叠扩展 — 正确性核心
└── format.rs # 输出塑形:偏移量、忙碌状态、会议URL、描述
```
重复时隙来自 [rrule] crate;所有其他ICS处理(解析、EXDATE、RECURRENCE-ID重叠、通过chrono-tz进行时区处理)都是第一方且固定的,由一套手动验证的Exchange风格日历数据固定。
**范围:** 此目标是Outlook/Exchange发布的订阅源。处理`RRULE`、`EXDATE`、`RECURRENCE-ID`重叠、`DURATION`、`SEQUENCE`和Windows时区ID;不处理`RDATE`、`STATUS:CANCELLED`、`TRANSP`和`RANGE=THISANDFUTURE` — Exchange不会发出这些,但其他日历源会发出。欢迎提出问题和补丁以支持更广泛的订阅源。
[AGENTS.md](AGENTS.md)有更深入的设计笔记 — 不变量、故意偏离以及看起来很奇怪的部分背后的原因。
## 开发
所有操作都在dockerized工具链(`dev` compose服务)中运行 — **不需要主机Rust工具链**,并且可以立即运行测试(它们使用固定数据,而不是您的真实订阅源,因此不需要`.env`):
```bash
git clone https://github.com/hromadkom/calendar-ics-mcp.git
cd calendar-ics-mcp
docker compose run --rm dev cargo test
```
循环的其余部分:
```bash
docker compose run --rm -e TZ=Pacific/Kiritimati dev cargo test # 主机时区独立性证明
docker compose run --rm dev cargo clippy --all-targets -- -D warnings
docker compose run --rm dev cargo fmt --check
docker build --target test . # 密封门:fmt + clippy + test
npx @modelcontextprotocol/inspector docker compose run --rm -T calendar-ics
```
`docker build --target test .`是门槛。没有任何操作在拉取请求上运行它 — 在打开拉取请求之前自己运行它(参见 [CONTRIBUTING.md](CONTRIBUTING.md))。发布工作流程运行相同的门槛,如果失败则拒绝发布镜像。
### 在本地运行镜像
`compose.yaml`包含与上述Claude Desktop块相同的强化运行配置,并读取`.env`,因此您不必重新输入标志:
```bash
cp .env.example .env # 填写ICS_URL
docker compose build # 构建本地镜像
docker compose run --rm -T calendar-ics # stdio JSON-RPC会话
```
两件事需要注意:这是一个stdio服务器,因此使用`compose run`而不是`compose up`(`up`的容器将无限期等待标准输入);并且在管道时保留`-T`:如果没有它,Compose会将stderr日志合并到stdout JSON-RPC流中。示例烟雾测试:
```bash
{ printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"feed_info","arguments":{}}}'; \
sleep 5; } | docker compose run --rm -T calendar-ics
```
(`sleep`保持标准输入打开,直到响应到达 — 服务器在标准输入EOF时故意关闭,即当MCP客户端断开连接时。)
要检查真实订阅源而不将URL放入shell历史记录中:
```bash
read -rs ICS_URL && export ICS_URL # 粘贴URL而不回显
npx @modelcontextprotocol/inspector \
docker run -i --rm -e ICS_URL hromadkom/calendar-ics-mcp:latest # 调用feed_info首先
```
`feed_info`应报告`ends_with_end_vcalendar: true`,`feed_bytes`与订阅源的完整大小匹配,以及合理的`vevent_count`。如果您的订阅源大于通用获取工具的响应上限,则这是此服务器的全部意义。
## 发布
发布由GitHub Releases驱动 — 本地无需运行任何操作:
1. 提升`Cargo.toml`中的`version`,刷新`Cargo.lock`(`docker compose run --rm dev cargo check`),并将[CHANGELOG.md](CHANGELOG.md)中的`[Unreleased]`条目移至新版本。提交。
2. 创建一个带有标签`vX.Y.Z`的GitHub Release。
[`.github/workflows/release.yml`](.github/workflows/release.yml)然后验证标签与`Cargo.toml`匹配,运行密封门(fmt + clippy +完整测试套件),并推送带有`:X.Y.Z`、`:<short-sha>`和`:latest`的多架构`linux/amd64` + `linux/arm64`镜像。标记版本为预发布版本会发布版本标签但保留`:latest`不变。
分叉通过设置存储库变量`IMAGE_NAME`和`REGISTRY`以及`DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`秘密发布到自己的命名空间。版本遵循 [Semantic Versioning](https://semver.org/)。
## 安全
简而言之:**您的订阅源URL是凭证**,服务器构建时不会让它逃脱。
- **只读,单一目的地。** 服务器曾经做出的唯一网络请求是对一个配置的`ICS_URL`进行`GET`。它不会在任何地方写入任何内容,不会调用任何Microsoft 365 API,不使用OAuth,并且不需要租户权限。
- **无持久性,无遥测。** 日历数据仅存在于容器内内存缓存(≤`CACHE_TTL_SECONDS`)中;响应仅发送到连接的MCP客户端。
- **URL从未泄漏。** 它从未被记录(错误将其屏蔽到源),从未烘焙到镜像中,从未放在命令行上,并且仅在运行时通过环境变量提供。测试套件断言这一点。
- **强化容器。** `FROM scratch`,带有单个静态二进制文件,以`USER 65534:65534`运行,标准输入模式下不暴露任何端口,依赖树由`Cargo.lock`固定并构建 `--locked`。
- **安全失败。** 未以`END:VCALENDAR`结尾的订阅源被视为截断:工具返回错误,而不是静默提供部分数据。
完整详细信息、HTTP模式威胁模型以及如何报告漏洞: [SECURITY.md](SECURITY.md)。
## 许可证
[MIT](LICENSE) © Martin Hromádko
[rrule]: https://github.com/fmeringdal/rust-rrule