内容
# icloud-mcp
[](https://github.com/ThomasCrouzet/icloud-mcp/actions/workflows/ci.yml)
[](LICENSE)
一个使用Go编写的**stdio MCP服务器**,通过CalDAV公开**苹果(iCloud)日历**,支持事件的读取和写入,具有最小且可审计的范围。一个单一的静态二进制文件,设计为作为任何MCP主机或代理网关的子进程运行,通过stdin/stdout进行JSON-RPC通信。无需网络监听器,无需额外设置。
**仅限日历**:永不涉及邮件、联系人、提醒事项或其他任何苹果服务。
## 技术栈
- **Go 1.25+**,模块。仅5个直接依赖:
- `github.com/emersion/go-webdav`(+`/caldav`):CalDAV客户端
- `github.com/emersion/go-ical`:iCalendar解析
- `github.com/mark3labs/mcp-go`:官方Go MCP SDK(stdio传输)
- `github.com/teambition/rrule-go`:重复规则扩展
- `golang.org/x/time`:速率限制
无其他直接依赖(无prometheus、godotenv、uuid、遥测)。
## MCP工具
| 工具 | 类型 | 描述 |
|------|------|-------------|
| `list_calendars` | 读取 | 列出日历(名称、路径、颜色、描述)。 |
| `search_events` | 读取 | 在指定日期范围内搜索事件(重复规则扩展、筛选、分页、最大400个事件)。 |
| `get_event` | 读取 | 通过日历路径和精确UID获取单个事件(包括etag、闹钟、状态)。 |
| `find_free_slots` | 读取 | 本地计算可用时间段;永不泄露繁忙事件标题。 |
| `validate_event` | 读取 | 创建事件的本地验证(无网络)。 |
| `calendar_capabilities` | 读取 | 版本、限制、功能(无机密信息、无路径、无网络)。 |
| `create_event` | 写入 | 创建事件(标题、开始时间、结束时间、日历;可选位置/备注/闹钟、`all_day`、`rrule`、状态、透明度、URL、`client_uid`)。 |
| `update_event` | 写入 | 通过UID修改事件(`scope`系列/事件、选定`etag` If-Match)。 |
| `delete_event` | 写入 | 通过UID删除事件(`scope`、`etag`、选定`dry_run`);回显标题以确认。 |
**`ICLOUD_MCP_READ_ONLY=1`** 删除`tools/list`中的3个写入工具(它们不会被执行,而是被拒绝)。 读取工具包括`get_event`、`find_free_slots`、`validate_event`和`calendar_capabilities`仍然可用。 这是推荐的初始部署模式。
更多文档: [架构](docs/architecture.md), [安全](docs/security.md), [CalDAV兼容性](docs/caldav-compatibility.md), [测试](docs/testing.md), [V2迁移](docs/v2-migration.md).
## 安装
### 使用`go install`安装
```bash
go install github.com/ThomasCrouzet/icloud-mcp/cmd/icloud-mcp@latest
```
二进制文件位于`$(go env GOPATH)/bin`;确保该目录在您的`PATH`中。
### 在MCP主机中配置
示例配置,适用于任何启动stdio服务器的MCP主机:
```json
{
"mcpServers": {
"icloud-calendar": {
"command": "icloud-mcp",
"env": {
"ICLOUD_EMAIL": "you@icloud.com",
"ICLOUD_PASSWORD": "your-app-specific-password",
"ICLOUD_MCP_READ_ONLY": "1",
"ICLOUD_MCP_DEFAULT_TZ": "Europe/Paris"
}
}
}
}
```
`ICLOUD_PASSWORD` 必须是 在 [appleid.apple.com](https://appleid.apple.com) 上生成的 **应用特定密码**,永不是您的主要苹果ID密码。 首先设置`ICLOUD_MCP_READ_ONLY=1`,只有在您信任您的设置时才解除限制。 将`ICLOUD_MCP_DEFAULT_TZ`设置为日历所有者的IANA时区,以便裸本地`start`/`end`时间(无偏移)正确解析;默认值为UTC。
大多数stdio MCP主机将此块存储在JSON配置文件中(确切的路径和添加服务器的UI因主机而异)。 使用绝对`command`路径,以便主机无论其工作目录如何都能找到二进制文件:
```json
{
"mcpServers": {
"icloud-calendar": {
"command": "/usr/local/bin/icloud-mcp",
"env": {
"ICLOUD_EMAIL": "you@icloud.com",
"ICLOUD_PASSWORD": "your-app-specific-password",
"ICLOUD_MCP_READ_ONLY": "1",
"ICLOUD_MCP_DEFAULT_TZ": "Europe/Paris"
}
}
}
}
```
保存后,重启主机。 一旦服务器连接,`list_calendars`和`search_events`立即可用(写入工具在`ICLOUD_MCP_READ_ONLY=1`时保持隐藏)。
#### 机密文件(Docker / 12-factor)
`file://`前缀从文件路径读取机密信息,而不将值放入主机配置或进程环境中:
```json
{
"mcpServers": {
"icloud-calendar": {
"command": "/usr/local/bin/icloud-mcp",
"env": {
"ICLOUD_EMAIL": "file:///run/secrets/icloud-email",
"ICLOUD_PASSWORD": "file:///run/secrets/icloud-password",
"ICLOUD_MCP_READ_ONLY": "1"
}
}
}
}
```
这是容器化部署的推荐模式(挂载机密文件为只读)。 这是二进制文件执行的唯一磁盘读取,在启动时。
## 配置
环境变量(参见`.env.example`):
| 变量 | 角色 |
|----------|------|
| `ICLOUD_EMAIL` | 苹果ID(电子邮件)。 支持`file://`前缀。 |
| `ICLOUD_PASSWORD` | 苹果 **应用特定密码**。 支持`file://`前缀。 |
| `ICLOUD_MCP_READ_ONLY` | `1`/`true` → 只读模式(不注册写入工具)。 |
| `ICLOUD_MCP_LOG_LEVEL` | `debug`/`info`/`warn`/`error` stderr的JSON日志(默认为`info`)。 |
| `ICLOUD_MCP_DEFAULT_TZ` | IANA时区(例如`Europe/Paris`)用于解释没有明确RFC3339偏移的`start`/`end`值。 默认`UTC`。 参见“日期和时区”以下。 |
`file://`前缀从文件中读取机密信息(类似Docker机密的模式):`ICLOUD_PASSWORD=file:///path/to/app-password`。 这是程序执行的**唯一**磁盘读取操作,仅在启动时。
密码必须是 在 [appleid.apple.com](https://appleid.apple.com) 上生成的 **应用特定密码**,永不是账户的主要密码。 可选标志`-health <addr>`:HTTP `/healthz`健康检查(默认禁用;永不绑定到`0.0.0.0`)。
### 日期和时区
`start`/`end`值(`create_event`、`update_event`、`search_events`)接受两种形式:
- **具有明确偏移的RFC3339**,例如`2026-07-01T14:00:00+02:00`或`2026-07-01T14:00:00Z`。 按字面意思解析:偏移量是调用者的刻意选择,始终如实处理。
- **具有无偏移的本地壁钟时间**,例如`2026-07-01T14:00:00`。 在`ICLOUD_MCP_DEFAULT_TZ`(DST感知)中解释,如果该变量未设置,则为UTC。
无偏移形式是推荐的“用户所说的时间”:它完全消除了调用代理的时区偏移算术,这是LLM经常出错的计算类型。 将`ICLOUD_MCP_DEFAULT_TZ`设置为日历所有者的IANA时区(例如`Europe/Paris`),以便在DST更改时正确解析裸本地小时,无需代理进行任何转换。 保留显式偏移形式以表示故意位于不同特定时区的值(例如,安排在UTC或另一个城市的本地时间)。
## 故障排除(CalDAV / iCloud)
服务器在启动时使用iCloud验证凭据(运行两个PROPFIND)。 如果启动失败,错误将作为JSON打印到stderr,进程以非零退出。 设置`ICLOUD_MCP_LOG_LEVEL=debug`以获取完整的发现跟踪。
| 症状 | 可能原因 | 解决方法 |
|---------|--------------|-----|
| `authentication_refused`(HTTP 401)在启动时 | 错误/吊销的应用特定密码,或`ICLOUD_EMAIL`中输入错误。 使用您要访问的日历的iCloud帐户的电子邮件。 | 在[appleid.apple.com](https://appleid.apple.com)上重新生成应用特定密码 → 登录和安全 → 应用特定密码。 它不是主要的苹果ID密码。 |
| `forbidden`(HTTP 403) | 应用特定密码被吊销,或日历达到最大50,000个事件(苹果配额)。 | 吊销并重新生成密码;或删除事件以回到配额以下。 |
| 发现成功,但在会话中无法访问分片(`pXX-caldav.icloud.com`) | 暂时的苹果分片故障。 | 服务器重试`502`/`503`/`504`,并带有`Retry-After` + 回退;如果分片持续关闭,则显示`server_unavailable`。 稍后重试。 |
| `rate_limited`(HTTP 429) | 苹果正在限制。 内置的60次读取/分钟和20次写入/分钟限制通常可以防止这种情况发生,但跨多个日历的突发可能会触发苹果的限制。 | 减少并发/请求频率。 HTTP层已经重试429,并带有`Retry-After`。 |
| `concurrent_modification`(`update_event`上的HTTP 412) | 另一个客户端(日历应用,另一个代理)在您的读取和更新之间修改了事件。 | 重新读取并重新应用更新。 `update_event`发送`If-Match`(ETag),因此检测到冲突而不是静默覆盖。 |
| 事件在DST更改时出现错误时间 | TZID在手动编辑时丢失(强制`.UTC()`在`Dtstart`上)。 | 此服务器通过GET-then-PUT保留TZID;请勿在服务器外部编辑原始`text/calendar`。 参见`internal/icloud/recurrence.go`。 |
| 新创建的事件与用户请求的时间相差1到2小时 | 调用代理发送了具有显式偏移的RFC3339 `start`/`end`(通常为`Z`/UTC),而不是预期的本地时间;始终如实处理偏移量。 | 让代理发送本地壁钟时间,无偏移(例如`2026-07-01T14:00:00`),并将`ICLOUD_MCP_DEFAULT_TZ`设置为日历所有者的IANA时区;参见上面的“日期和时区”。 |
| `search_events`对于已知的循环系列返回没有事件 | `EXDATE`排除事件,或`RECURRENCE-ID`覆盖它们。 | 检查主对象;覆盖作为其自己的事件出现,带有更改的时间。 |
### 苹果CalDAV限制
- 每个服务器实例仅限一个iCloud帐户(单个苹果ID)。
- 每个日历最多50,000个事件(苹果配额;超过此限制,写入返回403)。
- 重复规则扩展是有限的(保护免受无限RRULE的攻击);`search_events`窗口限制在366天内。
- `update_event`仅修改主VEVENT;`RECURRENCE-ID`覆盖保持不变。
- iCloud拒绝服务器端`prop-filter`查询(返回412),因此`findEventByUID`直接获取`<uid>.ics`,然后在导入的事件文件名与UID不同的情况下回退到广泛的时间范围扫描。
有关安全模型、威胁模型以及如何报告漏洞,请参见 [SECURITY.md](SECURITY.md)。
## 构建和测试
```bash
make build # 本地二进制(开发),主机工具链
make test # go test ./... -race -cover
make lint # go vet + golangci-lint
make release # 静态linux/arm64二进制文件,通过golang:1.25容器
make install # release + 复制到$(HOME)/.local/bin/icloud-mcp
```
生产二进制文件在 **`golang:1.25`容器**中编译(`CGO_ENABLED=0 GOOS=linux GOARCH=arm64 -trimpath -ldflags='-s -w'`);假设主机上没有Go工具链。 预期二进制文件:静态,剥离,< 15 MB。
## 威胁模型
该服务器作为代理进程或容器中的stdio子进程运行,由LLM驱动。 威胁模型假设 **驱动此服务器的代理可能被泄露或操纵**(提示注入等)。
### 服务器可以执行的操作
- 连接到 **单个**网络目的地:`https://caldav.icloud.com`及其分片`pXX-caldav.icloud.com`(硬编码允许列表,始终验证TLS,其他目的地拒绝,包括通过HTTP重定向)。
- 读取配置的iCloud帐户的日历和事件。
- 在写入模式(READ_ONLY禁用)下:创建、修改、删除事件。
### 服务器无法执行的操作
- 访问其他苹果服务: **永不** 邮件、联系人、提醒事项、照片、文件。 仅限CalDAV(日历)。
- 访问iCloud以外的任何网络目的地(严格的允许列表,拒绝非标准端口)。
- 执行命令(零`os/exec`)、写入磁盘(无状态;仅在启动时读取`file://`凭据)、或发出遥测数据。
- 泄露密码:从所有输出(日志、错误、MCP响应,包括panic时的协议错误路径)中删除。
### 如果驱动服务器的代理被泄露
在最坏的情况下,攻击者可以 **读取和(如果READ_ONLY被解除)修改或删除配置账户的日历**,而不能访问其他内容。 它无法泄露凭据(已删除),也不能跳转到其他服务或网络目的地。
**吊销**:`ICLOUD_PASSWORD`是 **应用特定密码**(而非主密码)。它可以随时在 [appleid.apple.com](https://appleid.apple.com) → 登录和安全 → 应用特定密码处吊销,对账户无影响。应用特定密码仅授予DAV服务访问权限,而此服务器仅使用CalDAV范围的访问权限。
## 安全模型(实现)
| 机制 | 详情 |
|-----------|--------|
| **网络允许列表** | 一个`http.RoundTripper`,在DNS解析之前拒绝任何非`caldav.icloud.com`/`pXX-caldav.icloud.com`的主机、任何非https的方案和任何显式端口;涵盖每个重定向跳转(+`CheckRedirect`)。发现的Shard主机被重新验证。 |
| **TLS** | 始终验证,`MinVersion` TLS 1.2,永不使用`InsecureSkipVerify`。 |
| **机密删除** | 密码(及其基本身份验证的base64形式和URL编码形式)和电子邮件从所有输出中删除:stderr(slog +审计 +传输日志,标准库`log`),MCP响应(删除错误助手 +删除恢复中间件,用于JSON-RPC通道)。 |
| **变更审计** | 每次创建/更新/删除都记录到stderr作为结构化JSON(时间戳,级别,`msg=audit`,工具,日历,UID,状态),**不含** 标题或内容(无PII)。 |
| **速率限制** | 60次读取/分钟,20次写入/分钟(`x/time/rate`令牌桶),30秒HTTP超时,25秒每工具超时,有界重试和回退(仅限幂等操作)。来自CalDAV分片的HTTP 429/502/503/504被重试,并带有`Retry-After`确认 +指数回退/抖动(6次尝试,有界于工具超时)。 |
| **输入验证** | RFC3339日期(尊重偏移量)或通过`ICLOUD_MCP_DEFAULT_TZ`解析的本地时间,范围≤366天,UID/路径不含`..`/NUL,有界字段大小。 |
| **最小化表面** | 无状态,零`os/exec`,零磁盘写入,零遥测。 内存中仅缓存的发现数据(`sync.Once`覆盖分片基址 +日历主页集)和速率限制器令牌桶(进程范围的节流器,而非每客户端会话状态)。 没有请求依赖于前一个请求的变更状态:`update_event`通过GET重新读取事件,因此它从不跨调用携带可变状态。 |
## 测试
使用模拟的CalDAV服务器(`httptest`)驱动的表格测试,涵盖:完整的列表/搜索/创建/更新/删除循环,分片发现,身份验证错误(401),速率限制,RRULE/EXDATE/覆盖展开(包括DST更改时的TZID保留),网络允许列表和 **端到端删除** (特殊的密码从未出现在任何输出中,并带有正控制)。 实际集成测试(`//go:build integration`)会访问实际的iCloud,默认情况下跳过,在CI中永不运行。
```bash
go test ./... -race -cover
```
## 致谢
本项目灵感来自工具结构、速率限制/重试装饰器模式、缓存的分片发现(`sync.Once`)以及基于指针的`EventUpdate`模式,来自
[`github.com/roygabriel/mcp-icloud-calendar`](https://github.com/roygabriel/mcp-icloud-calendar)(MIT许可证,© 2026 Gabe)。 代码 **重写而非复制**。 未沿用:多账户支持,godotenv,prometheus/指标,google/uuid,mTLS,请求ID中间件。
显著的差异和修复:单个账户,**硬网络允许列表**(参考文献中没有),机密删除,自定义分片发现通过PROPFIND(go-webdav v0.7.0丢失分片主机),正确的EXDATE + RECURRENCE-ID覆盖展开,保留TZID,处理缺少的DTEND,硬上限400个结果。
## 已知限制
- **更新使用机会主义的If-Match**:`update_event`重新读取完整的对象(`<uid>.ics`上的GET或REPORT备用用于导入的文件名)并在可用时发送`If-Match`(GET头或REPORT `getetag`)。 当服务器省略ETag时,PUT降级为无条件的最后写入者获胜。 go-webdav v0.7.0 `PutCalendarObject`不支持`If-Match`,因此有条件PUT是手工制作的。
- **UID回退窗口**:如果`<uid>.ics`丢失,`findEventByUID`扫描当前时间约±5年(而不是1970-2100)。 完全位于该窗口之外的事件可能无法找到以进行更新/删除。
- **重复规则展开**限制在2000个事件/序列;当达到上限时,`search_events`设置`truncatedByExpansion`。 结果还硬上限为400个事件(`truncated`)。 多日历搜索在持有400个 **匹配** 事件时停止新的日历;可选查询在预算之前按日历应用,因此非匹配项无法隐藏后面的日历。
- **RDATE**(额外重复日期)不展开;仅RRULE + EXDATE + RECURRENCE-ID覆盖。
- **create_event**可以写入全天事件和可选的RRULE(仅限主事件)。 有时间戳的事件始终写入UTC,从不带有TZID + VTIMEZONE:发出正确的VTIMEZONE需要完整的DST转换表,而固定的偏移量会错误地放置在过渡的另一侧的任何事件。 `ICLOUD_MCP_DEFAULT_TZ`因此控制裸输入时间如何读取,而不是事件如何存储。
- **可选的`-health`**:仅环回绑定被接受(`127.0.0.1`,`::1`,`localhost`);拒绝`0.0.0.0` / 裸`:port`。
- **单个iCloud帐户**每个实例。
- 工具错误是具有分类的CalDAV错误时的JSON对象`{"code":"...","message":"..."}`(对于普通的验证错误,省略`code`)。
## 贡献
在打开PR之前,请运行`gofmt`、`make test`(`go test -race`)和`make lint`(`go vet` + `golangci-lint`)。 保持依赖列表最小;任何新的直接依赖都需要理由。 PRs 欢迎。
### MCP SDK (`mark3labs/mcp-go`) 监视
此服务器固定`mark3labs/mcp-go`并使用稳定的API:`NewMCPServer`,`WithToolCapabilities`/`WithRecovery`/`WithInstructions`/`ToolHandlerMiddleware`,`AddTool`,`ServeStdio`,`WithErrorLogger`。 它 **不** 依赖于`initialize`握手或任何MCP级授权(唯一认证是CalDAV HTTP层的Basic Auth)。 在升级`mcp-go`到新的次版本/主版本之前,请检查更改日志中是否删除了`initialize`或新的授权模型,并运行`make test`。 Dependabot打开升级PR;根据上述固定依赖策略审查它们。 服务器必须保持100%无状态(无每请求可变状态跨调用):参见安全模型表中的“最小化表面”行。
## 许可证
MIT。 参见 [LICENSE](LICENSE) 文件。
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器