diff --git a/README.md b/README.md index 0a2a086..2a795a9 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,14 @@ Here is an example of configuring the remote service, specifying toolsets and re { "mcpServers": { "flashduty": { - "url": "https://mcp.flashcat.cloud/flashduty?toolsets=flashduty_incidents,flashduty_teams&read_only=true", + "url": "https://mcp.flashcat.cloud/flashduty?toolsets=incidents,users&read_only=true", "authorization_token": "Bearer " } } } ``` -- `toolsets=...`: Use a comma-separated list to specify the toolsets to enable. +- `toolsets=...`: Use a comma-separated list to specify the toolsets to enable (e.g., `incidents,users,channels`). - `read_only=true`: Enables read-only mode. ### Local Server Configuration @@ -168,6 +168,7 @@ This is the most common method for local configuration, especially in a Docker e | `FLASHDUTY_APP_KEY` | Flashduty APP key | ✅ | - | | `FLASHDUTY_TOOLSETS` | Toolsets to enable (comma-separated) | ❌ | All toolsets | | `FLASHDUTY_READ_ONLY` | Restrict to read-only operations (`1` or `true`) | ❌ | `false` | +| `FLASHDUTY_OUTPUT_FORMAT` | Output format for tool results (`json` or `toon`) | ❌ | `json` | | `FLASHDUTY_BASE_URL` | Flashduty API base URL | ❌ | `https://api.flashcat.cloud` | | `FLASHDUTY_LOG_FILE` | Log file path | ❌ | stderr | | `FLASHDUTY_ENABLE_COMMAND_LOGGING` | Enable command logging | ❌ | `false` | @@ -177,7 +178,7 @@ This is the most common method for local configuration, especially in a Docker e ```bash docker run -i --rm \ -e FLASHDUTY_APP_KEY= \ - -e FLASHDUTY_TOOLSETS="flashduty_incidents,flashduty_teams" \ + -e FLASHDUTY_TOOLSETS="incidents,users,channels" \ -e FLASHDUTY_READ_ONLY=1 \ registry.flashcat.cloud/public/flashduty-mcp-server ``` @@ -189,7 +190,7 @@ If you build and run the binary directly from the source, you can use command-li ```bash ./flashduty-mcp-server stdio \ --app-key your_app_key_here \ - --toolsets flashduty_incidents,flashduty_teams \ + --toolsets incidents,users,channels \ --read-only ``` @@ -197,6 +198,7 @@ Available command-line arguments: - `--app-key`: Flashduty APP key (alternative to `FLASHDUTY_APP_KEY` environment variable) - `--toolsets`: Comma-separated list of toolsets to enable - `--read-only`: Enable read-only mode +- `--output-format`: Output format for tool results (`json` or `toon`) - `--base-url`: Flashduty API base URL - `--log-file`: Path to log file - `--enable-command-logging`: Enable command logging @@ -204,7 +206,38 @@ Available command-line arguments: > Note: Command-line arguments take precedence over environment variables. For toolsets configuration, if both `FLASHDUTY_TOOLSETS` environment variable and `--toolsets` argument are set, the command-line argument takes priority. -#### 3. i18n / Overriding Descriptions (Local-Only) +#### 3. Output Format (TOON) + +The server supports [TOON (Token-Oriented Object Notation)](https://github.com/toon-format/toon) format for tool results, which can significantly reduce token usage (30-50%) when working with LLMs. + +**JSON output** (default): +```json +{"members":[{"person_id":1,"person_name":"Alice"},{"person_id":2,"person_name":"Bob"}],"total":2} +``` + +**TOON output** (compact): +``` +members[2]{person_id,person_name}: + 1,Alice + 2,Bob +total: 2 +``` + +To enable TOON format: + +**Via Environment Variable:** +```bash +export FLASHDUTY_OUTPUT_FORMAT=toon +``` + +**Via Command-line:** +```bash +./flashduty-mcp-server stdio --output-format toon +``` + +> **Note:** TOON format is particularly effective for arrays of objects with uniform fields (e.g., member lists, incident lists). Most modern LLMs can parse TOON format naturally. + +#### 4. i18n / Overriding Descriptions (Local-Only) The feature to override tool descriptions is only available for local deployments. You can achieve this by creating a `flashduty-mcp-server-config.json` file or by setting environment variables. @@ -230,49 +263,48 @@ export FLASHDUTY_MCP_TOOL_CREATE_INCIDENT_DESCRIPTION="an alternative descriptio The following toolsets are available (all are on by default). You can also use `all` to enable all toolsets. -| Toolset | Description | -| ----------------------- | ------------------------------------------------------------- | -| `flashduty_incidents` | Flashduty incident management tools | -| `flashduty_members` | Flashduty member management tools | -| `flashduty_teams` | Flashduty team management tools | -| `flashduty_channels` | Flashduty collaboration channel management tools | +| Toolset | Description | Tools | +| -------------- | ------------------------------------------------ | ----- | +| `incidents` | Incident lifecycle management | 6 | +| `changes` | Change record query | 1 | +| `status_page` | Status page management | 4 | +| `users` | Member and team query | 2 | +| `channels` | Collaboration space and escalation rules | 2 | +| `fields` | Custom field definitions | 1 | + +**Total: 16 tools** --- ## Tools -The server provides the following toolsets based on Flashduty API: - -### `flashduty_members` - Member Management Tools -- `flashduty_member_infos` - Get member information by person IDs - -### `flashduty_teams` - Team Management Tools -- `flashduty_teams_infos` - Get team information by team IDs - -### `flashduty_channels` - Channel Management Tools -- `flashduty_channels_infos` - Get collaboration space information by channel IDs - -### `flashduty_incidents` - Incident Management Tools -- `flashduty_incidents_infos` - Get incident information by incident IDs -- `flashduty_list_incidents` - List incidents with comprehensive filters -- `flashduty_list_past_incidents` - List similar historical incidents -- `flashduty_get_incident_timeline` - Get incident timeline and feed -- `flashduty_get_incident_alerts` - Get alerts associated with incidents -- `flashduty_create_incident` - Create a new incident -- `flashduty_ack_incident` - Acknowledge incidents -- `flashduty_resolve_incident` - Resolve incidents -- `flashduty_assign_incident` - Assign incidents to people or escalation rules -- `flashduty_add_responder` - Add responders to incidents -- `flashduty_snooze_incident` - Snooze incidents for a period -- `flashduty_merge_incident` - Merge multiple incidents into one -- `flashduty_comment_incident` - Add comments to incidents -- `flashduty_update_incident_title` - Update incident title -- `flashduty_update_incident_description` - Update incident description -- `flashduty_update_incident_impact` - Update incident impact -- `flashduty_update_incident_root_cause` - Update incident root cause -- `flashduty_update_incident_resolution` - Update incident resolution -- `flashduty_update_incident_severity` - Update incident severity -- `flashduty_update_incident_fields` - Update custom fields +### `incidents` - Incident Lifecycle Management (6 tools) +- `query_incidents` - Query incidents with enriched data (timeline, alerts, responders) +- `create_incident` - Create a new incident +- `update_incident` - Update incident (title, description, severity, custom_fields) +- `ack_incident` - Acknowledge incidents +- `close_incident` - Close (resolve) incidents +- `list_similar_incidents` - Find similar historical incidents + +### `changes` - Change Record Query (1 tool) +- `query_changes` - Query change records with filters + +### `status_page` - Status Page Management (4 tools) +- `query_status_pages` - Query status pages with full configuration +- `list_status_changes` - List change events on status page +- `create_status_incident` - Create incident on status page +- `create_change_timeline` - Add timeline update to status change + +### `users` - Member and Team Query (2 tools) +- `query_members` - Query members with optional filters +- `query_teams` - Query teams with member details + +### `channels` - Collaboration Space and Escalation Rules (2 tools) +- `query_channels` - Query collaboration spaces +- `query_escalation_rules` - Query escalation rules for a channel + +### `fields` - Custom Field Definitions (1 tool) +- `query_fields` - Query custom field definitions --- diff --git a/README_zh.md b/README_zh.md index 4ac70ab..fb9df32 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,29 +1,29 @@ -# Flashduty MCP 服务 +# Flashduty MCP Server -中文 | [English](README.md) +[English](README.md) | 中文 -Flashduty MCP 服务是一个 [模型上下文协议 (MCP)](https://modelcontextprotocol.io/introduction) 服务,它提供了与 Flashduty API 的无缝集成,为开发人员和工具提供了高级的故障管理和自动化功能。 +Flashduty MCP Server 是一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) 的服务,提供与 Flashduty API 的无缝对接,帮助开发者和工具实现智能化的故障管理与自动化运维。 -### 使用场景 +### 应用场景 -- 自动化 Flashduty 工作流和流程。 -- 从 Flashduty 提取和分析数据。 -- 构建与 Flashduty 交互的 AI 驱动的工具和应用程序。 +- 自动化 Flashduty 工作流程 +- 从 Flashduty 抽取和分析数据 +- 构建与 Flashduty 交互的 AI 工具和应用 --- -## 远程 Flashduty MCP 服务 +## 远程服务 -[![在 Cursor 中安装](https://img.shields.io/badge/Cursor-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](#remote-cursor) +[![在 Cursor 中安装](https://img.shields.io/badge/Cursor-安装服务-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](#remote-cursor) -远程 Flashduty MCP 服务提供了与 Flashduty 集成的最简单方法。如果您的 MCP 主机不支持远程 MCP 服务,您可以改用[本地版本的 Flashduty MCP 服务](#local-flashduty-mcp-server)。 +远程服务是接入 Flashduty 最便捷的方式。如果你的 MCP 客户端不支持远程服务,可以使用[本地服务](#本地服务)。 -## 先决条件 +### 前置条件 -1. 支持最新 MCP 规范和远程服务的 MCP 主机,例如 [Cursor](https://www.cursor.com/)。 -2. 来自您 Flashduty 账户的 Flashduty APP 密钥。 +1. 支持 MCP 协议的客户端,如 [Cursor](https://www.cursor.com/) +2. Flashduty 账户的 APP Key -## 安装 +### 配置示例 @@ -33,36 +33,33 @@ Flashduty MCP 服务是一个 [模型上下文协议 (MCP)](https://modelcontext { "mcpServers": { "flashduty": { - "url": "https://mcp.flashcat.cloud/flashduty?toolsets=flashduty_incidents,flashduty_teams&read_only=true", + "url": "https://mcp.flashcat.cloud/flashduty", "authorization_token": "Bearer " } } } ``` -> **注意:** 有关远程 MCP 服务设置的正确语法和位置,请参阅 Cursor 的文档。 +> **提示:** 具体配置位置请参考你所使用的 MCP 客户端文档。 --- -## 本地 Flashduty MCP 服务 +## 本地服务 -[![在 Cursor 中使用 Docker 安装](https://img.shields.io/badge/Cursor-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](#local-cursor) +[![在 Cursor 中安装](https://img.shields.io/badge/Cursor-安装服务-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](#local-cursor) -## 先决条件 +### 前置条件 -1. 要在容器中运行该服务,您需要安装 [Docker](https://www.docker.com/)。 -2. 安装 Docker 后,您还需要确保 Docker 正在运行。 -3. 最后,您需要从您的 Flashduty 帐户获取一个 Flashduty APP 密钥。 +1. 如需容器化运行,请先安装并启动 [Docker](https://www.docker.com/) +2. Flashduty 账户的 APP Key -## 安装 +### 配置示例 以 Cursor 为例: -### 使用 Docker - -将以下 JSON 块添加到您的 Cursor MCP 配置中。 +#### Docker 方式 ```json { @@ -85,15 +82,11 @@ Flashduty MCP 服务是一个 [模型上下文协议 (MCP)](https://modelcontext } ``` -### 使用二进制 - -除了通过源码构建,您也可以直接从本项目的 [GitHub Releases](https://github.com/flashcatcloud/flashduty-mcp-server/releases) 页面下载适用于您操作系统的预编译版本,这是一个更快捷方便的选项。 +#### 二进制方式 -如果您没有 Docker,您可以使用 `go build` 在 `cmd/flashtudy-mcp-server` 目录中构建二进制文件。您可以通过环境变量或命令行参数提供 APP 密钥。 +你可以从 [GitHub Releases](https://github.com/flashcatcloud/flashduty-mcp-server/releases) 下载预编译的二进制文件,也可以通过 `go build` 在 `cmd/flashduty-mcp-server` 目录下自行构建。 -您应该配置 Cursor 使用构建的可执行文件作为其 `command`。例如: - -**通过环境变量:** +**环境变量方式:** ```json { "mcpServers": { @@ -108,7 +101,7 @@ Flashduty MCP 服务是一个 [模型上下文协议 (MCP)](https://modelcontext } ``` -**通过命令行参数:** +**命令行参数方式:** ```json { "mcpServers": { @@ -120,167 +113,184 @@ Flashduty MCP 服务是一个 [模型上下文协议 (MCP)](https://modelcontext } ``` -> **注意:** 有关 MCP 服务设置的正确语法和位置,请参阅 Cursor 的文档。 - --- -## 工具配置 +## 配置选项 -Flashduty MCP 服务支持多种配置选项,以满足不同的使用场景。主要配置项包括: +Flashduty MCP Server 支持以下配置: -- **工具集 (Toolsets)**: 允许您启用或禁用特定的功能组。仅启用需要的工具集可以帮助 LLM 更准确地选择工具,并减小上下文大小。 -- **只读模式 (Read-Only)**: 将服务限制在只读模式,禁止任何修改性操作,增强安全性。 -- **国际化 (i18n)**: 支持自定义工具的描述,以适应不同的语言或团队偏好。 +- **工具集 (Toolsets)**:按功能分组启用/禁用工具,减少上下文大小,帮助 LLM 更精准地选择工具 +- **只读模式 (Read-Only)**:禁止写操作,适用于安全要求较高的场景 +- **输出格式 (Output Format)**:支持 JSON 和 TOON 格式,TOON 格式可减少 30-50% 的 token 消耗 +- **国际化 (i18n)**:支持自定义工具描述 -配置方式主要分为 **远程服务配置** 和 **本地服务配置** 两种。 +### 远程服务配置 -### 远程工具配置 - -当您使用公共远程服务 (`https://mcp.flashcat.cloud/flashduty`) 时,可以通过在 URL 中附加查询参数来动态配置服务。 - -#### 配置示例 - -以下是在 Cursor 中配置远程服务并指定工具集和只读模式的示例: +通过 URL 参数动态配置: ```json { "mcpServers": { "flashduty": { - "url": "https://mcp.flashcat.cloud/flashduty?toolsets=flashduty_incidents,flashduty_teams&read_only=true", + "url": "https://mcp.flashcat.cloud/flashduty?toolsets=incidents,users&read_only=true", "authorization_token": "Bearer " } } } ``` -- `toolsets=...`: 使用逗号分隔的列表来指定要启用的工具集。 -- `read_only=true`: 启用只读模式。 - -### 本地工具配置 +- `toolsets=...`:启用指定的工具集,多个用逗号分隔 +- `read_only=true`:启用只读模式 -当您通过 Docker 或源码在本地运行服务时,拥有完全的配置权限。 +### 本地服务配置 -#### 1. 通过环境变量配置 +#### 1. 环境变量 -这是最常见的本地配置方式,尤其是在 Docker 环境中。 - -| 变量 | 描述 | 必需 | 默认值 | +| 变量 | 说明 | 必填 | 默认值 | |---|---|---|---| -| `FLASHDUTY_APP_KEY` | Flashduty APP 密钥 | ✅ | - | -| `FLASHDUTY_TOOLSETS` | 要启用的工具集(逗号分隔) | ❌ | 所有工具集 | -| `FLASHDUTY_READ_ONLY` | 限制为只读操作 (`1` 或 `true`) | ❌ | `false` | -| `FLASHDUTY_BASE_URL` | Flashduty API 基础 URL | ❌ | `https://api.flashcat.cloud` | +| `FLASHDUTY_APP_KEY` | Flashduty APP Key | ✅ | - | +| `FLASHDUTY_TOOLSETS` | 启用的工具集(逗号分隔) | ❌ | 全部 | +| `FLASHDUTY_READ_ONLY` | 只读模式(`1` 或 `true`) | ❌ | `false` | +| `FLASHDUTY_OUTPUT_FORMAT` | 输出格式(`json` 或 `toon`) | ❌ | `json` | +| `FLASHDUTY_BASE_URL` | API 地址 | ❌ | `https://api.flashcat.cloud` | | `FLASHDUTY_LOG_FILE` | 日志文件路径 | ❌ | stderr | -| `FLASHDUTY_ENABLE_COMMAND_LOGGING` | 启用命令日志记录 | ❌ | `false` | +| `FLASHDUTY_ENABLE_COMMAND_LOGGING` | 记录请求日志 | ❌ | `false` | -**Docker 示例:** +**Docker 示例:** ```bash docker run -i --rm \ -e FLASHDUTY_APP_KEY= \ - -e FLASHDUTY_TOOLSETS="flashduty_incidents,flashduty_teams" \ + -e FLASHDUTY_TOOLSETS="incidents,users,channels" \ -e FLASHDUTY_READ_ONLY=1 \ registry.flashcat.cloud/public/flashduty-mcp-server ``` -#### 2. 通过命令行参数配置 - -如果您直接从源码构建和运行二进制文件,可以使用命令行参数。 +#### 2. 命令行参数 ```bash ./flashduty-mcp-server stdio \ --app-key your_app_key_here \ - --toolsets flashduty_incidents,flashduty_teams \ + --toolsets incidents,users,channels \ --read-only ``` -可用的命令行参数: -- `--app-key`: Flashduty APP 密钥(替代 `FLASHDUTY_APP_KEY` 环境变量) -- `--toolsets`: 要启用的工具集(逗号分隔) -- `--read-only`: 启用只读模式 -- `--base-url`: Flashduty API 基础 URL -- `--log-file`: 日志文件路径 -- `--enable-command-logging`: 启用命令日志记录 -- `--export-translations`: 将翻译保存到 JSON 文件 +支持的参数: +- `--app-key`:Flashduty APP Key +- `--toolsets`:启用的工具集 +- `--read-only`:只读模式 +- `--output-format`:输出格式(`json` 或 `toon`) +- `--base-url`:API 地址 +- `--log-file`:日志文件路径 +- `--enable-command-logging`:记录请求日志 +- `--export-translations`:导出翻译配置 + +> **注意:** 命令行参数优先级高于环境变量。 + +#### 3. TOON 输出格式 -> 注意:命令行参数的优先级高于环境变量。对于工具集配置,如果同时设置了 `FLASHDUTY_TOOLSETS` 环境变量和 `--toolsets` 参数,命令行参数优先生效。 +服务支持 [TOON (Token-Oriented Object Notation)](https://github.com/toon-format/toon) 格式,可显著降低 token 消耗(约 30-50%),特别适合 LLM 场景。 -#### 3. 国际化 / 覆盖描述 (仅限本地) +**JSON 格式(默认):** +```json +{"members":[{"person_id":1,"person_name":"Alice"},{"person_id":2,"person_name":"Bob"}],"total":2} +``` -覆盖工具描述的功能仅在本地部署时可用。您可以通过创建 `flashduty-mcp-server-config.json` 文件或设置环境变量来实现。 +**TOON 格式(紧凑):** +``` +members[2]{person_id,person_name}: + 1,Alice + 2,Bob +total: 2 +``` -**通过 JSON 文件:** +启用方式: + +```bash +# 环境变量 +export FLASHDUTY_OUTPUT_FORMAT=toon + +# 命令行参数 +./flashduty-mcp-server stdio --output-format toon +``` + +> **提示:** TOON 格式对统一结构的对象数组(如成员列表、故障列表)效果最佳,主流 LLM 均可正确解析。 + +#### 4. 国际化 / 自定义描述(仅本地) + +可通过配置文件或环境变量覆盖工具描述: + +**配置文件方式:** 在二进制文件同目录下创建 `flashduty-mcp-server-config.json`: ```json { - "TOOL_CREATE_INCIDENT_DESCRIPTION": "an alternative description", - "TOOL_LIST_TEAMS_DESCRIPTION": "List all teams in Flashduty account" + "TOOL_CREATE_INCIDENT_DESCRIPTION": "自定义描述", + "TOOL_LIST_TEAMS_DESCRIPTION": "列出账户中的所有团队" } ``` -**通过环境变量:** +**环境变量方式:** ```sh -export FLASHDUTY_MCP_TOOL_CREATE_INCIDENT_DESCRIPTION="an alternative description" +export FLASHDUTY_MCP_TOOL_CREATE_INCIDENT_DESCRIPTION="自定义描述" ``` --- -## 可用的工具集 +## 工具集 -以下是所有可用的工具集,默认全部启用。您也可以使用 `all` 来代表所有工具集。 +默认启用全部工具集,也可使用 `all` 代表全部。 -| 工具集 | 描述 | -| --- | --- | -| `flashduty_incidents` | Flashduty 故障管理工具 | -| `flashduty_members` | Flashduty 成员管理工具 | -| `flashduty_teams` | Flashduty 团队管理工具 | -| `flashduty_channels` | Flashduty 协作空间管理工具 | +| 工具集 | 说明 | 工具数 | +| --- | --- | --- | +| `incidents` | 故障生命周期管理 | 6 | +| `changes` | 变更记录查询 | 1 | +| `status_page` | 状态页管理 | 4 | +| `users` | 成员和团队查询 | 2 | +| `channels` | 协作空间和分派策略 | 2 | +| `fields` | 自定义字段定义 | 1 | + +**共计 16 个工具** --- -## 工具 - -该服务基于 Flashduty API 提供以下工具集: - -### `flashduty_members` - 成员管理工具 -- `flashduty_member_infos` - 通过人员 ID 获取成员信息 - -### `flashduty_teams` - 团队管理工具 -- `flashduty_teams_infos` - 通过团队 ID 获取团队信息 - -### `flashduty_channels` - 协作空间管理工具 -- `flashduty_channels_infos` - 通过协作空间 ID 获取协作空间信息 - -### `flashduty_incidents` - 故障管理工具 -- `flashduty_incidents_infos` - 通过故障 ID 获取故障信息 -- `flashduty_list_incidents` - 使用综合过滤器列出故障 -- `flashduty_list_past_incidents` - 列出类似的历史故障 -- `flashduty_get_incident_timeline` - 获取故障时间线和动态 -- `flashduty_get_incident_alerts` - 获取与故障相关的警报 -- `flashduty_create_incident` - 创建一个新故障 -- `flashduty_ack_incident` - 确认故障 -- `flashduty_resolve_incident` - 解决故障 -- `flashduty_assign_incident` - 将故障分配给人员或升级规则 -- `flashduty_add_responder` - 为故障添加响应者 -- `flashduty_snooze_incident` - 暂停故障一段时间 -- `flashduty_merge_incident` - 将多个故障合并为一个 -- `flashduty_comment_incident` - 为故障添加评论 -- `flashduty_update_incident_title` - 更新故障标题 -- `flashduty_update_incident_description` - 更新故障描述 -- `flashduty_update_incident_impact` - 更新故障影响 -- `flashduty_update_incident_root_cause` - 更新故障根本原因 -- `flashduty_update_incident_resolution` - 更新故障解决方案 -- `flashduty_update_incident_severity` - 更新故障严重性 -- `flashduty_update_incident_fields` - 更新自定义字段 +## 工具列表 + +### `incidents` - 故障管理 (6) +- `query_incidents` - 查询故障(含时间线、告警、响应人等完整信息) +- `create_incident` - 创建故障 +- `update_incident` - 更新故障(标题、描述、严重程度、自定义字段) +- `ack_incident` - 认领故障 +- `close_incident` - 关闭故障 +- `list_similar_incidents` - 查找相似历史故障 + +### `changes` - 变更管理 (1) +- `query_changes` - 查询变更记录 + +### `status_page` - 状态页 (4) +- `query_status_pages` - 查询状态页配置 +- `list_status_changes` - 查询状态页变更事件 +- `create_status_incident` - 创建状态页故障 +- `create_change_timeline` - 添加变更时间线 + +### `users` - 成员管理 (2) +- `query_members` - 查询成员 +- `query_teams` - 查询团队(含成员详情) + +### `channels` - 协作空间 (2) +- `query_channels` - 查询协作空间 +- `query_escalation_rules` - 查询分派规则 + +### `fields` - 字段管理 (1) +- `query_fields` - 查询自定义字段定义 --- -## 库的使用 +## 作为库使用 -该模块导出的 Go API 目前应被视为不稳定,并可能发生重大更改。将来,我们可能会提供稳定性;如果有用例认为这很有价值,请提交问题。 +本项目导出的 Go API 目前处于不稳定状态,可能会有 breaking changes。如有稳定 API 需求,欢迎提 Issue。 -## 许可证 +## 开源协议 -该项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 \ No newline at end of file +本项目基于 MIT 协议开源,详见 [LICENSE](LICENSE) 文件。 diff --git a/cmd/flashduty-mcp-server/main.go b/cmd/flashduty-mcp-server/main.go index 86b75dd..e1d7092 100644 --- a/cmd/flashduty-mcp-server/main.go +++ b/cmd/flashduty-mcp-server/main.go @@ -6,11 +6,12 @@ import ( "os" "strings" - "github.com/flashcatcloud/flashduty-mcp-server/internal/flashduty" - flashdutyPkg "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" + + "github.com/flashcatcloud/flashduty-mcp-server/internal/flashduty" + flashdutyPkg "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" ) // These variables are set by the build process using ldflags. @@ -60,6 +61,7 @@ var ( APPKey: appKey, EnabledToolsets: enabledToolsets, ReadOnly: viper.GetBool("read-only"), + OutputFormat: viper.GetString("output-format"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), LogFilePath: viper.GetString("log-file"), @@ -72,7 +74,7 @@ var ( Use: "http", Short: "Start HTTP server", Long: `Start a streamable HTTP server.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { httpServerConfig := flashduty.HTTPServerConfig{ Version: version, Commit: commit, @@ -95,6 +97,7 @@ func init() { rootCmd.PersistentFlags().String("app-key", "", "Flashduty APP key (can also be set via FLASHDUTY_APP_KEY environment variable)") rootCmd.PersistentFlags().StringSlice("toolsets", flashdutyPkg.DefaultTools, "An optional comma separated list of groups of tools to allow, defaults to enabling all") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") + rootCmd.PersistentFlags().String("output-format", "json", "Output format for tool results: json (default) or toon (Token-Oriented Object Notation for reduced token usage)") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") @@ -107,6 +110,7 @@ func init() { _ = viper.BindPFlag("app_key", rootCmd.PersistentFlags().Lookup("app-key")) _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) + _ = viper.BindPFlag("output-format", rootCmd.PersistentFlags().Lookup("output-format")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) diff --git a/go.mod b/go.mod index e581752..e3adfe8 100644 --- a/go.mod +++ b/go.mod @@ -3,29 +3,32 @@ module github.com/flashcatcloud/flashduty-mcp-server go 1.24.4 require ( + github.com/bluele/gcache v0.0.2 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 - github.com/mark3labs/mcp-go v0.32.0 - github.com/migueleliasweb/go-github-mock v1.4.0 + github.com/mark3labs/mcp-go v0.43.2 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c + golang.org/x/sync v0.15.0 ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect - github.com/google/go-github/v73 v73.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/time v0.11.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 6e566f4..1b1a6ad 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= +github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,16 +26,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= -github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= -github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -47,10 +51,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= -github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= -github.com/migueleliasweb/go-github-mock v1.4.0 h1:pQ6K8r348m2q79A8Khb0PbEeNQV7t3h1xgECV+jNpXk= -github.com/migueleliasweb/go-github-mock v1.4.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= @@ -84,6 +86,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c h1:D8lDFovBMZywze1eh9iwMLcYor5f11mHBocLhO7cBe8= +github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c/go.mod h1:j/BOnpF2ihnz4lELs99h9mwGJBx/zdleOUCnLLRPCsc= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= @@ -92,13 +98,13 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index 14d29bc..b054d52 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -3,7 +3,9 @@ package flashduty import ( "context" "fmt" - "sync" + "time" + + "github.com/bluele/gcache" "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" ) @@ -37,7 +39,9 @@ func contextWithClient(ctx context.Context, client *flashduty.Client) context.Co return context.WithValue(ctx, flashdutyClientKey, client) } -var clientCache = &sync.Map{} // map[string]*flashduty.Client +var clientCache = gcache.New(1000). + Expiration(time.Hour). + Build() // getClientFromContext is a helper function for tool handlers to get a flashduty client. // It will try to get the client from the context first. If not found, it will create a new one @@ -57,8 +61,9 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) return ctx, nil, fmt.Errorf("flashduty app key is not configured") } - // Use APP key as cache key, assuming one key corresponds to one client configuration. - if client, ok := clientCache.Load(cfg.APPKey); ok { + // Use APP key and BaseURL as cache key to handle different environments. + cacheKey := fmt.Sprintf("%s|%s", cfg.APPKey, cfg.BaseURL) + if client, err := clientCache.Get(cacheKey); err == nil { return contextWithClient(ctx, client.(*flashduty.Client)), client.(*flashduty.Client), nil } @@ -69,7 +74,7 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) return ctx, nil, fmt.Errorf("failed to create Flashduty client: %w", err) } - clientCache.Store(cfg.APPKey, client) + _ = clientCache.Set(cacheKey, client) ctx = contextWithClient(ctx, client) return ctx, client, nil diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index d6f89a7..e92c78c 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -13,13 +13,14 @@ import ( "syscall" "time" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" + pkgerrors "github.com/flashcatcloud/flashduty-mcp-server/pkg/errors" "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" mcplog "github.com/flashcatcloud/flashduty-mcp-server/pkg/log" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/sirupsen/logrus" ) type FlashdutyConfig struct { @@ -115,6 +116,9 @@ type StdioServerConfig struct { // ReadOnly indicates if we should only register read-only tools ReadOnly bool + // OutputFormat specifies the format for tool results (json or toon) + OutputFormat string + // ExportTranslations indicates if we should export translations ExportTranslations bool @@ -131,6 +135,9 @@ func RunStdioServer(cfg StdioServerConfig) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // Set the global output format + flashduty.SetOutputFormat(flashduty.ParseOutputFormat(cfg.OutputFormat)) + t, dumpTranslations := translations.TranslationHelper() flashdutyServer, err := NewMCPServer(FlashdutyConfig{ @@ -165,11 +172,6 @@ func RunStdioServer(cfg StdioServerConfig) error { stdLogger := log.New(logrusLogger.Writer(), "stdioserver", 0) stdioServer.SetErrorLogger(stdLogger) - if cfg.ExportTranslations { - // Once server is initialized, all translations are loaded - dumpTranslations() - } - // Start listening for messages errC := make(chan error, 1) go func() { diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go index 89d02e1..2b27560 100644 --- a/internal/toolsnaps/toolsnaps.go +++ b/internal/toolsnaps/toolsnaps.go @@ -68,12 +68,12 @@ func Test(toolName string, tool any) error { func writeSnap(snapPath string, contents []byte) error { // Ensure the directory exists - if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(snapPath), 0o700); err != nil { return fmt.Errorf("failed to create snapshot directory: %w", err) } // Write the snapshot file - if err := os.WriteFile(snapPath, contents, 0600); err != nil { + if err := os.WriteFile(snapPath, contents, 0o600); err != nil { return fmt.Errorf("failed to write snapshot file: %w", err) } diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go index be9cadf..64eef12 100644 --- a/internal/toolsnaps/toolsnaps_test.go +++ b/internal/toolsnaps/toolsnaps_test.go @@ -65,8 +65,8 @@ func TestSnapshotExistsMatch(t *testing.T) { // Given a matching snapshot file exists tool := dummyTool{"foo", 42} b, _ := json.MarshalIndent(tool, "", " ") - require.NoError(t, os.MkdirAll("__toolsnaps__", 0700)) - require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), b, 0600)) + require.NoError(t, os.MkdirAll("__toolsnaps__", 0o700)) + require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), b, 0o600)) // When we test the snapshot err := Test("dummy", tool) @@ -82,8 +82,8 @@ func TestSnapshotExistsDiff(t *testing.T) { t.Setenv("UPDATE_TOOLSNAPS", "false") // Given a non-matching snapshot file exists - require.NoError(t, os.MkdirAll("__toolsnaps__", 0700)) - require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0600)) + require.NoError(t, os.MkdirAll("__toolsnaps__", 0o700)) + require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0o600)) tool := dummyTool{"foo", 2} // When we test the snapshot @@ -99,8 +99,8 @@ func TestUpdateToolsnaps(t *testing.T) { // Given UPDATE_TOOLSNAPS is set, regardless of whether a matching snapshot file exists t.Setenv("UPDATE_TOOLSNAPS", "true") - require.NoError(t, os.MkdirAll("__toolsnaps__", 0700)) - require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0600)) + require.NoError(t, os.MkdirAll("__toolsnaps__", 0o700)) + require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0o600)) tool := dummyTool{"foo", 42} // When we test the snapshot @@ -120,8 +120,8 @@ func TestMalformedSnapshotJSON(t *testing.T) { t.Setenv("UPDATE_TOOLSNAPS", "false") // Given a malformed snapshot file exists - require.NoError(t, os.MkdirAll("__toolsnaps__", 0700)) - require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`not-json`), 0600)) + require.NoError(t, os.MkdirAll("__toolsnaps__", 0o700)) + require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`not-json`), 0o600)) tool := dummyTool{"foo", 42} // When we test the snapshot diff --git a/pkg/flashduty/changes.go b/pkg/flashduty/changes.go new file mode 100644 index 0000000..3e723c1 --- /dev/null +++ b/pkg/flashduty/changes.go @@ -0,0 +1,185 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "golang.org/x/sync/errgroup" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +const queryChangesDescription = `Query change records with filters. + +Change records track deployments, configurations, and other operational changes +that may be correlated with incidents. + +**Parameters:** +- change_ids (optional): Comma-separated change IDs for direct lookup +- channel_id (optional): Filter by collaboration space ID +- start_time, end_time (optional): Unix timestamp range +- type (optional): Filter by change type +- limit (optional): Max results (default 20) + +**Returns:** +- Change records with enriched channel and creator names` + +// QueryChanges creates a tool to query change records +func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_changes", + mcp.WithDescription(t("TOOL_QUERY_CHANGES_DESCRIPTION", queryChangesDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_CHANGES_USER_TITLE", "Query changes"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("change_ids", mcp.Description("Comma-separated change IDs")), + mcp.WithNumber("channel_id", mcp.Description("Filter by collaboration space ID")), + mcp.WithNumber("start_time", mcp.Description("Start time (Unix timestamp)")), + mcp.WithNumber("end_time", mcp.Description("End time (Unix timestamp)")), + mcp.WithString("type", mcp.Description("Filter by change type")), + mcp.WithNumber("limit", mcp.Description("Max results (default 20)")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + changeIdsStr, _ := OptionalParam[string](request, "change_ids") + channelID, _ := OptionalInt(request, "channel_id") + startTime, _ := OptionalInt(request, "start_time") + endTime, _ := OptionalInt(request, "end_time") + changeType, _ := OptionalParam[string](request, "type") + limit, _ := OptionalInt(request, "limit") + + if limit <= 0 { + limit = 20 + } + + requestBody := map[string]interface{}{ + "p": 1, + "limit": limit, + } + + if changeIdsStr != "" { + changeIDs := parseCommaSeparatedStrings(changeIdsStr) + requestBody["change_ids"] = changeIDs + } + if channelID > 0 { + requestBody["channel_id"] = channelID + } + if startTime > 0 { + requestBody["start_time"] = startTime + } + if endTime > 0 { + requestBody["end_time"] = endTime + } + if changeType != "" { + requestBody["type"] = changeType + } + + resp, err := client.makeRequest(ctx, "POST", "/change/list", requestBody) + if err != nil { + return nil, fmt.Errorf("failed to query changes: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API failed with status %d", resp.StatusCode)), nil + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + ChangeID string `json:"change_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + CreatorID int64 `json:"creator_id,omitempty"` + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + } `json:"items"` + Total int `json:"total"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + if result.Data == nil || len(result.Data.Items) == 0 { + return MarshalResult(map[string]any{ + "changes": []Change{}, + "total": 0, + }), nil + } + + // Collect IDs for enrichment + channelIDs := make([]int64, 0) + personIDs := make([]int64, 0) + for _, item := range result.Data.Items { + if item.ChannelID != 0 { + channelIDs = append(channelIDs, item.ChannelID) + } + if item.CreatorID != 0 { + personIDs = append(personIDs, item.CreatorID) + } + } + + // Fetch enrichment data concurrently + var channelMap map[int64]ChannelInfo + var personMap map[int64]PersonInfo + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + channelMap, _ = client.fetchChannelInfos(gctx, channelIDs) + return nil + }) + + g.Go(func() error { + personMap, _ = client.fetchPersonInfos(gctx, personIDs) + return nil + }) + + _ = g.Wait() // Ignore errors for enrichment as it's best-effort + + // Build enriched changes + changes := make([]Change, 0, len(result.Data.Items)) + for _, item := range result.Data.Items { + change := Change{ + ChangeID: item.ChangeID, + Title: item.Title, + Description: item.Description, + Type: item.Type, + Status: item.Status, + ChannelID: item.ChannelID, + CreatorID: item.CreatorID, + StartTime: item.StartTime, + EndTime: item.EndTime, + Labels: item.Labels, + } + + if ch, ok := channelMap[item.ChannelID]; ok { + change.ChannelName = ch.ChannelName + } + if p, ok := personMap[item.CreatorID]; ok { + change.CreatorName = p.PersonName + } + + changes = append(changes, change) + } + + return MarshalResult(map[string]any{ + "changes": changes, + "total": result.Data.Total, + }), nil + } +} diff --git a/pkg/flashduty/channels.go b/pkg/flashduty/channels.go index f9f7320..fff4803 100644 --- a/pkg/flashduty/channels.go +++ b/pkg/flashduty/channels.go @@ -4,125 +4,254 @@ import ( "context" "fmt" "net/http" - "strconv" "strings" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) -// ChannelInfos creates a tool to get collaboration space information by channel IDs -func ChannelInfos(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_channels_infos", - mcp.WithDescription(t("TOOL_FLASHDUTY_CHANNELS_INFOS_DESCRIPTION", "Get collaboration space information by channel IDs")), +const queryChannelsDescription = `Query collaboration spaces (channels). + +**Parameters:** +- channel_ids (optional): Comma-separated channel IDs for direct lookup +- name (optional): Search by channel name + +**Returns:** +- Channel list with team information` + +// QueryChannels creates a tool to query channels +func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_channels", + mcp.WithDescription(t("TOOL_QUERY_CHANNELS_DESCRIPTION", queryChannelsDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_CHANNELS_INFOS_USER_TITLE", "Get channel infos"), + Title: t("TOOL_QUERY_CHANNELS_USER_TITLE", "Query channels"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("channel_ids", - mcp.Required(), - mcp.Description("Comma-separated list of channel IDs to get information for. Example: '123,456,789'"), - ), + mcp.WithString("channel_ids", mcp.Description("Comma-separated channel IDs")), + mcp.WithString("name", mcp.Description("Search by channel name")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Extract channel_ids from request - channelIdsStr, err := RequiredParam[string](request, "channel_ids") + ctx, client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - // Parse comma-separated string to int slice - var channelIdsInt []int + channelIdsStr, _ := OptionalParam[string](request, "channel_ids") + name, _ := OptionalParam[string](request, "name") + + // Query by channel IDs if channelIdsStr != "" { - parts := strings.Split(channelIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - id, err := strconv.Atoi(part) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid channel_id: %s", part)), nil - } - channelIdsInt = append(channelIdsInt, id) - } + channelIDs := parseCommaSeparatedInts(channelIdsStr) + if len(channelIDs) == 0 { + return mcp.NewToolResultError("channel_ids must contain at least one valid ID when specified"), nil } - } - if len(channelIdsInt) == 0 { - return mcp.NewToolResultError("channel_ids cannot be empty"), nil - } + int64IDs := make([]int64, len(channelIDs)) + for i, id := range channelIDs { + int64IDs[i] = int64(id) + } - // Get Flashduty client - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } + channelMap, err := client.fetchChannelInfos(ctx, int64IDs) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve channels: %v", err)), nil + } - // Build request body according to API specification - requestBody := map[string]interface{}{ - "channel_ids": channelIdsInt, + channels := make([]ChannelInfo, 0, len(channelMap)) + for _, ch := range channelMap { + channels = append(channels, ch) + } + + return MarshalResult(map[string]any{ + "channels": channels, + "total": len(channels), + }), nil } - // Make API request to /channel/infos endpoint - resp, err := client.makeRequest(ctx, "POST", "/channel/infos", requestBody) + // List all channels + resp, err := client.makeRequest(ctx, "POST", "/channel/list", map[string]interface{}{}) if err != nil { - return nil, fmt.Errorf("failed to get channel infos: %w", err) + return nil, fmt.Errorf("unable to list channels: %w", err) } - defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("API request failed with status %d", resp.StatusCode)), nil + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil } - // Parse response - var result FlashdutyResponse + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + TeamID int64 `json:"team_id,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - - // Check for API error if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(result.Data), nil + channels := []ChannelInfo{} + if result.Data != nil { + for _, ch := range result.Data.Items { + // Filter by name if provided (case-insensitive substring match) + if name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(name)) { + continue + } + channels = append(channels, ChannelInfo{ + ChannelID: ch.ChannelID, + ChannelName: ch.ChannelName, + TeamID: ch.TeamID, + }) + } + } + + return MarshalResult(map[string]any{ + "channels": channels, + "total": len(channels), + }), nil } } -func ListChannels(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_list_channels", - mcp.WithDescription(t("TOOL_FLASHDUTY_LIST_CHANNELS_DESCRIPTION", "List all collaboration channels")), +const queryEscalationRulesDescription = `Query escalation rules for a collaboration space. + +**Parameters:** +- channel_id (required): Collaboration space ID + +**Returns:** +- Escalation rules with layers and targets (enriched with names)` + +// QueryEscalationRules creates a tool to query escalation rules +func QueryEscalationRules(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_escalation_rules", + mcp.WithDescription(t("TOOL_QUERY_ESCALATION_RULES_DESCRIPTION", queryEscalationRulesDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_LIST_CHANNELS_USER_TITLE", "List channels"), + Title: t("TOOL_QUERY_ESCALATION_RULES_USER_TITLE", "Query escalation rules"), ReadOnlyHint: ToBoolPtr(true), }), + mcp.WithNumber("channel_id", mcp.Required(), mcp.Description("Collaboration space ID")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ctx, client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get flashduty client: %w", err) + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - resp, err := client.makeRequest(ctx, "GET", "/channel/list", nil) + + channelID, err := RequiredInt(request, "channel_id") if err != nil { - return nil, fmt.Errorf("failed to get channel list: %w", err) + return mcp.NewToolResultError(err.Error()), nil } + requestBody := map[string]interface{}{ + "channel_id": channelID, + } + + resp, err := client.makeRequest(ctx, "POST", "/channel/escalate/rule/list", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to query escalation rules: %w", err) + } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("API request failed with status %d", resp.StatusCode)), nil + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil } - // Parse response - var result FlashdutyResponse + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + RuleID string `json:"rule_id"` + RuleName string `json:"rule_name"` + Description string `json:"description,omitempty"` + ChannelID int64 `json:"channel_id"` + Status string `json:"status"` + Layers []struct { + MaxTimes int `json:"max_times"` + NotifyStep int `json:"notify_step"` + EscalateWindow int `json:"escalate_window"` + Target struct { + PersonIDs []int64 `json:"person_ids,omitempty"` + } `json:"target,omitempty"` + } `json:"layers,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - - // Check for API error if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(result.Data), nil + rules := []EscalationRule{} + if result.Data != nil { + // Collect all person IDs for enrichment + personIDs := make([]int64, 0) + for _, r := range result.Data.Items { + for _, l := range r.Layers { + for _, pid := range l.Target.PersonIDs { + if pid != 0 { + personIDs = append(personIDs, pid) + } + } + } + } + + // Fetch person info (graceful degradation: continue without names if fetch fails) + personMap, err := client.fetchPersonInfos(ctx, personIDs) + if err != nil { + personMap = make(map[int64]PersonInfo) + } + + // Build enriched rules + for _, r := range result.Data.Items { + rule := EscalationRule{ + RuleID: r.RuleID, + RuleName: r.RuleName, + Description: r.Description, + ChannelID: r.ChannelID, + Status: r.Status, + } + + if len(r.Layers) > 0 { + rule.Layers = make([]EscalationLayer, 0, len(r.Layers)) + for idx, l := range r.Layers { + layer := EscalationLayer{ + LayerIdx: idx, + Timeout: l.EscalateWindow, + NotifyInterval: l.NotifyStep, + MaxTimes: l.MaxTimes, + } + + if len(l.Target.PersonIDs) > 0 { + layer.Targets = make([]EscalationTarget, 0, len(l.Target.PersonIDs)) + for _, pid := range l.Target.PersonIDs { + target := EscalationTarget{ + Type: "person", + ID: pid, + } + if p, ok := personMap[pid]; ok { + target.Name = p.PersonName + } + layer.Targets = append(layer.Targets, target) + } + } + + rule.Layers = append(rule.Layers, layer) + } + } + + rules = append(rules, rule) + } + } + + return MarshalResult(map[string]any{ + "rules": rules, + "total": len(rules), + }), nil } } diff --git a/pkg/flashduty/channels_test.go b/pkg/flashduty/channels_test.go deleted file mode 100644 index 2427267..0000000 --- a/pkg/flashduty/channels_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package flashduty - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestChannelInfos(t *testing.T) { - responses := map[string]interface{}{ - "/channel/infos": map[string]interface{}{ - "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"channel_id": 1, "name": "Slack Channel", "type": "slack"}, - map[string]interface{}{"channel_id": 2, "name": "Teams Channel", "type": "teams"}, - }, - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := ChannelInfos(getClient, translator) - assert.Equal(t, "flashduty_channels_infos", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "channel_ids": "1,2", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "Slack Channel") - assert.Contains(t, textResult.Text, "Teams Channel") -} diff --git a/pkg/flashduty/client.go b/pkg/flashduty/client.go index d016fa0..3ca4e5d 100644 --- a/pkg/flashduty/client.go +++ b/pkg/flashduty/client.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "time" ) // Client represents a Flashduty API client @@ -38,10 +39,12 @@ func NewClient(appKey, baseURL, userAgent string) (*Client, error) { } return &Client{ - httpClient: &http.Client{}, - baseURL: parsedURL, - appKey: appKey, - userAgent: userAgent, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + baseURL: parsedURL, + appKey: appKey, + userAgent: userAgent, }, nil } @@ -62,8 +65,14 @@ func (c *Client) makeRequest(ctx context.Context, method, path string, body inte reqBody = bytes.NewBuffer(jsonBody) } + // Parse path to handle query parameters correctly + parsedPath, err := url.Parse(strings.TrimPrefix(path, "/")) + if err != nil { + return nil, fmt.Errorf("failed to parse path: %w", err) + } + // Construct full URL with app_key query parameter - fullURL := c.baseURL.ResolveReference(&url.URL{Path: strings.TrimPrefix(path, "/")}) + fullURL := c.baseURL.ResolveReference(parsedPath) query := fullURL.Query() query.Set("app_key", c.appKey) fullURL.RawQuery = query.Encode() @@ -74,7 +83,9 @@ func (c *Client) makeRequest(ctx context.Context, method, path string, body inte } // Set headers - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } req.Header.Set("Accept", "application/json") if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -82,16 +93,43 @@ func (c *Client) makeRequest(ctx context.Context, method, path string, body inte resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to make request: %w", err) + // Sanitize error to avoid leaking app_key in logs + return nil, fmt.Errorf("failed to make request to %s %s: %v", method, sanitizeURL(fullURL), sanitizeError(err)) } return resp, nil } -// parseResponse parses the HTTP response into the given interface -func parseResponse(resp *http.Response, v interface{}) error { - defer func() { _ = resp.Body.Close() }() +// sanitizeURL removes sensitive query parameters from URL for safe logging +func sanitizeURL(u *url.URL) string { + sanitized := *u + q := sanitized.Query() + if q.Has("app_key") { + q.Set("app_key", "[REDACTED]") + } + sanitized.RawQuery = q.Encode() + return sanitized.String() +} + +// sanitizeError removes potential URL with sensitive data from error messages +func sanitizeError(err error) string { + errStr := err.Error() + // Remove any app_key=xxx patterns from error messages + if idx := strings.Index(errStr, "app_key="); idx != -1 { + // Find the end of the app_key value (next & or end of string) + endIdx := strings.IndexAny(errStr[idx:], "& ") + if endIdx == -1 { + errStr = errStr[:idx] + "app_key=[REDACTED]" + } else { + errStr = errStr[:idx] + "app_key=[REDACTED]" + errStr[idx+endIdx:] + } + } + return errStr +} +// parseResponse parses the HTTP response into the given interface. +// Note: caller is responsible for closing resp.Body. +func parseResponse(resp *http.Response, v interface{}) error { body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) diff --git a/pkg/flashduty/enrichment.go b/pkg/flashduty/enrichment.go new file mode 100644 index 0000000..bae4d67 --- /dev/null +++ b/pkg/flashduty/enrichment.go @@ -0,0 +1,568 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" + + "golang.org/x/sync/errgroup" +) + +// RawTimelineItem represents raw timeline data from API +type RawTimelineItem struct { + Type string `json:"type"` + CreatedAt int64 `json:"created_at"` + PersonID int64 `json:"person_id,omitempty"` + Detail map[string]any `json:"detail,omitempty"` +} + +// fetchIncidentTimeline fetches timeline for a single incident +func (c *Client) fetchIncidentTimeline(ctx context.Context, incidentID string) ([]RawTimelineItem, error) { + requestBody := map[string]interface{}{ + "incident_id": incidentID, + "limit": 100, + "asc": true, + } + + resp, err := c.makeRequest(ctx, "POST", "/incident/feed", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to fetch timeline: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("timeline API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []RawTimelineItem `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + + if result.Data == nil { + return nil, nil + } + + return result.Data.Items, nil +} + +// fetchIncidentAlerts fetches alerts for a single incident +func (c *Client) fetchIncidentAlerts(ctx context.Context, incidentID string, limit int) ([]AlertPreview, int, error) { + requestBody := map[string]interface{}{ + "incident_id": incidentID, + "p": 1, + "limit": limit, + } + + resp, err := c.makeRequest(ctx, "POST", "/incident/alert/list", requestBody) + if err != nil { + return nil, 0, fmt.Errorf("unable to fetch alerts: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("alerts API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Total int `json:"total"` + Items []struct { + AlertID string `json:"alert_id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + TriggerTime int64 `json:"trigger_time"` + Labels map[string]string `json:"labels,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, 0, err + } + if result.Error != nil { + return nil, 0, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + + if result.Data == nil { + return nil, 0, nil + } + + alerts := make([]AlertPreview, 0, len(result.Data.Items)) + for _, item := range result.Data.Items { + alerts = append(alerts, AlertPreview{ + AlertID: item.AlertID, + Title: item.Title, + Severity: item.Severity, + Status: item.Status, + StartTime: item.TriggerTime, + Labels: item.Labels, + }) + } + return alerts, result.Data.Total, nil +} + +// fetchPersonInfos fetches person information by IDs +func (c *Client) fetchPersonInfos(ctx context.Context, personIDs []int64) (map[int64]PersonInfo, error) { + if len(personIDs) == 0 { + return make(map[int64]PersonInfo), nil + } + + // Deduplicate person IDs + idSet := make(map[int64]struct{}) + for _, id := range personIDs { + if id != 0 { + idSet[id] = struct{}{} + } + } + uniqueIDs := make([]int64, 0, len(idSet)) + for id := range idSet { + uniqueIDs = append(uniqueIDs, id) + } + + if len(uniqueIDs) == 0 { + return make(map[int64]PersonInfo), nil + } + + requestBody := map[string]interface{}{ + "person_ids": uniqueIDs, + } + + resp, err := c.makeRequest(ctx, "POST", "/person/infos", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to fetch person information: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("person API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + PersonID int64 `json:"person_id"` + PersonName string `json:"person_name"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` + As string `json:"as,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + + personMap := make(map[int64]PersonInfo) + if result.Data != nil { + for _, item := range result.Data.Items { + personMap[item.PersonID] = PersonInfo{ + PersonID: item.PersonID, + PersonName: item.PersonName, + Email: item.Email, + Avatar: item.Avatar, + As: item.As, + } + } + } + return personMap, nil +} + +// fetchChannelInfos fetches channel information by IDs +func (c *Client) fetchChannelInfos(ctx context.Context, channelIDs []int64) (map[int64]ChannelInfo, error) { + if len(channelIDs) == 0 { + return make(map[int64]ChannelInfo), nil + } + + // Deduplicate channel IDs + idSet := make(map[int64]struct{}) + for _, id := range channelIDs { + if id != 0 { + idSet[id] = struct{}{} + } + } + uniqueIDs := make([]int64, 0, len(idSet)) + for id := range idSet { + uniqueIDs = append(uniqueIDs, id) + } + + if len(uniqueIDs) == 0 { + return make(map[int64]ChannelInfo), nil + } + + requestBody := map[string]interface{}{ + "channel_ids": uniqueIDs, + } + + resp, err := c.makeRequest(ctx, "POST", "/channel/infos", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to fetch channel information: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("channel API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + TeamID int64 `json:"team_id,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + + channelMap := make(map[int64]ChannelInfo) + if result.Data != nil { + for _, item := range result.Data.Items { + channelMap[item.ChannelID] = ChannelInfo{ + ChannelID: item.ChannelID, + ChannelName: item.ChannelName, + TeamID: item.TeamID, + } + } + } + return channelMap, nil +} + +// RawIncident represents raw incident data from API +type RawIncident struct { + IncidentID string `json:"incident_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Severity string `json:"incident_severity"` + Progress string `json:"progress"` + StartTime int64 `json:"start_time"` + AckTime int64 `json:"ack_time,omitempty"` + CloseTime int64 `json:"close_time,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + CreatorID int64 `json:"creator_id,omitempty"` + CloserID int64 `json:"closer_id,omitempty"` + Responders []RawResponder `json:"responders,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +// RawResponder represents raw responder data from API +type RawResponder struct { + PersonID int64 `json:"person_id"` + AssignedAt int64 `json:"assigned_at,omitempty"` + AcknowledgedAt int64 `json:"acknowledged_at,omitempty"` +} + +// enrichIncidents enriches incidents with person and channel names (without timeline/alerts) +func (c *Client) enrichIncidents(ctx context.Context, rawIncidents []RawIncident) ([]EnrichedIncident, error) { + // Collect all person IDs and channel IDs + personIDs := make([]int64, 0) + channelIDs := make([]int64, 0) + + for _, inc := range rawIncidents { + if inc.CreatorID != 0 { + personIDs = append(personIDs, inc.CreatorID) + } + if inc.CloserID != 0 { + personIDs = append(personIDs, inc.CloserID) + } + for _, r := range inc.Responders { + if r.PersonID != 0 { + personIDs = append(personIDs, r.PersonID) + } + } + if inc.ChannelID != 0 { + channelIDs = append(channelIDs, inc.ChannelID) + } + } + + // Fetch person and channel info concurrently + var personMap map[int64]PersonInfo + var channelMap map[int64]ChannelInfo + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + personMap, err = c.fetchPersonInfos(ctx, personIDs) + return err + }) + + g.Go(func() error { + var err error + channelMap, err = c.fetchChannelInfos(ctx, channelIDs) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + // Build enriched incidents + enriched := make([]EnrichedIncident, 0, len(rawIncidents)) + for _, raw := range rawIncidents { + inc := EnrichedIncident{ + IncidentID: raw.IncidentID, + Title: raw.Title, + Description: raw.Description, + Severity: raw.Severity, + Progress: raw.Progress, + StartTime: raw.StartTime, + AckTime: raw.AckTime, + CloseTime: raw.CloseTime, + ChannelID: raw.ChannelID, + CreatorID: raw.CreatorID, + CloserID: raw.CloserID, + Labels: raw.Labels, + CustomFields: raw.Fields, + } + + // Enrich channel name + if ch, ok := channelMap[raw.ChannelID]; ok { + inc.ChannelName = ch.ChannelName + } + + // Enrich creator + if p, ok := personMap[raw.CreatorID]; ok { + inc.CreatorName = p.PersonName + inc.CreatorEmail = p.Email + } + + // Enrich closer + if p, ok := personMap[raw.CloserID]; ok { + inc.CloserName = p.PersonName + } + + // Enrich responders + if len(raw.Responders) > 0 { + inc.Responders = make([]EnrichedResponder, 0, len(raw.Responders)) + for _, r := range raw.Responders { + er := EnrichedResponder{ + PersonID: r.PersonID, + AssignedAt: r.AssignedAt, + AcknowledgedAt: r.AcknowledgedAt, + } + if p, ok := personMap[r.PersonID]; ok { + er.PersonName = p.PersonName + er.Email = p.Email + } + inc.Responders = append(inc.Responders, er) + } + } + + enriched = append(enriched, inc) + } + + return enriched, nil +} + +// collectTimelinePersonIDs extracts all person IDs from timeline items (including nested IDs in detail) +func collectTimelinePersonIDs(items []RawTimelineItem) []int64 { + personIDs := make([]int64, 0) + + for _, item := range items { + // Operator ID + if item.PersonID != 0 { + personIDs = append(personIDs, item.PersonID) + } + + // Extract person IDs from detail based on event type + if item.Detail == nil { + continue + } + + switch item.Type { + case "i_assign", "i_a_rspd": + // "to" field contains person IDs + if to, ok := item.Detail["to"].([]interface{}); ok { + for _, v := range to { + if id, ok := toInt64(v); ok && id != 0 { + personIDs = append(personIDs, id) + } + } + } + // "person_ids" field + if pids, ok := item.Detail["person_ids"].([]interface{}); ok { + for _, v := range pids { + if id, ok := toInt64(v); ok && id != 0 { + personIDs = append(personIDs, id) + } + } + } + case "i_notify": + // "to" field in notify events + if to, ok := item.Detail["to"].([]interface{}); ok { + for _, v := range to { + if id, ok := toInt64(v); ok && id != 0 { + personIDs = append(personIDs, id) + } + } + } + } + } + + return personIDs +} + +// toInt64 converts interface{} to int64 +func toInt64(v interface{}) (int64, bool) { + switch n := v.(type) { + case float64: + return int64(n), true + case int64: + return n, true + case int: + return int64(n), true + default: + return 0, false + } +} + +// enrichTimelineItems enriches raw timeline items with person names +func enrichTimelineItems(items []RawTimelineItem, personMap map[int64]PersonInfo) []TimelineEvent { + events := make([]TimelineEvent, 0, len(items)) + + for _, item := range items { + event := TimelineEvent{ + Type: item.Type, + Timestamp: item.CreatedAt, + OperatorID: item.PersonID, + } + + // Enrich operator name + if p, ok := personMap[item.PersonID]; ok { + event.OperatorName = p.PersonName + } + + // Build enriched detail based on event type + event.Detail = enrichTimelineDetail(item.Type, item.Detail, personMap) + + events = append(events, event) + } + + return events +} + +// enrichTimelineDetail enriches the detail field based on event type +func enrichTimelineDetail(eventType string, detail map[string]any, personMap map[int64]PersonInfo) any { + if detail == nil { + return nil + } + + // Create a copy of detail to avoid modifying the original + enriched := make(map[string]any) + for k, v := range detail { + enriched[k] = v + } + + switch eventType { + case "i_comm": + // Comment event - just return as is + return enriched + + case "i_notify": + // Notification event - enrich "to" person IDs + if to, ok := detail["to"].([]interface{}); ok { + enrichedTo := make([]map[string]any, 0, len(to)) + for _, v := range to { + if id, ok := toInt64(v); ok { + entry := map[string]any{"person_id": id} + if p, ok := personMap[id]; ok { + entry["person_name"] = p.PersonName + } + enrichedTo = append(enrichedTo, entry) + } + } + enriched["to"] = enrichedTo + } + return enriched + + case "i_assign", "i_a_rspd": + // Assignment event - enrich "to" and "person_ids" + if to, ok := detail["to"].([]interface{}); ok { + enrichedTo := make([]map[string]any, 0, len(to)) + for _, v := range to { + if id, ok := toInt64(v); ok { + entry := map[string]any{"person_id": id} + if p, ok := personMap[id]; ok { + entry["person_name"] = p.PersonName + } + enrichedTo = append(enrichedTo, entry) + } + } + enriched["to"] = enrichedTo + } + if pids, ok := detail["person_ids"].([]interface{}); ok { + enrichedPids := make([]map[string]any, 0, len(pids)) + for _, v := range pids { + if id, ok := toInt64(v); ok { + entry := map[string]any{"person_id": id} + if p, ok := personMap[id]; ok { + entry["person_name"] = p.PersonName + } + enrichedPids = append(enrichedPids, entry) + } + } + enriched["person_ids"] = enrichedPids + } + return enriched + + case "i_ack", "i_unack", "i_wake": + // Simple events without nested person IDs + return enriched + + case "i_snooze": + // Snooze event - has "minutes" field + return enriched + + case "i_rslv": + // Resolve event - has "from" field + return enriched + + case "i_reopen": + // Reopen event - has "reason" field + return enriched + + case "i_merge": + // Merge event - has source/target incidents + return enriched + + case "i_new": + // New incident event + return enriched + + case "i_r_rc", "i_r_desc", "i_r_rsltn", "i_r_resp", "i_r_impact", "i_r_title", "i_r_severity", "i_r_field": + // Field update events + return enriched + + case "i_m_silence", "i_m_inhibat", "i_m_flapping", "i_storm": + // Suppression events + return enriched + + case "i_custom": + // Custom action event + return enriched + + default: + // Unknown type - return as is + return enriched + } +} diff --git a/pkg/flashduty/fields.go b/pkg/flashduty/fields.go new file mode 100644 index 0000000..d1d7649 --- /dev/null +++ b/pkg/flashduty/fields.go @@ -0,0 +1,126 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +const queryFieldsDescription = `Query custom field definitions. + +Custom fields allow extending incident data with additional attributes. +Use this tool to discover available fields before updating incidents. + +**Parameters:** +- field_ids (optional): Comma-separated field IDs for direct lookup +- field_name (optional): Search by field name + +**Returns:** +- Field definitions including: + - field_type: checkbox, multi_select, single_select, text + - value_type: string, bool, float + - options: available values for select fields + - default_value: default value if set` + +// QueryFields creates a tool to query custom field definitions +func QueryFields(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_fields", + mcp.WithDescription(t("TOOL_QUERY_FIELDS_DESCRIPTION", queryFieldsDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_FIELDS_USER_TITLE", "Query fields"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("field_ids", mcp.Description("Comma-separated field IDs")), + mcp.WithString("field_name", mcp.Description("Search by field name")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + fieldIdsStr, _ := OptionalParam[string](request, "field_ids") + fieldName, _ := OptionalParam[string](request, "field_name") + + // List all fields + resp, err := client.makeRequest(ctx, "POST", "/field/list", map[string]any{}) + if err != nil { + return nil, fmt.Errorf("failed to list fields: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API failed with status %d", resp.StatusCode)), nil + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + FieldID string `json:"field_id"` + FieldName string `json:"field_name"` + DisplayName string `json:"display_name"` + FieldType string `json:"field_type"` + ValueType string `json:"value_type"` + Options []string `json:"options,omitempty"` + DefaultValue any `json:"default_value,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + // Parse filter IDs + var filterIDs []string + if fieldIdsStr != "" { + filterIDs = parseCommaSeparatedStrings(fieldIdsStr) + } + + fields := []FieldInfo{} + if result.Data != nil { + for _, f := range result.Data.Items { + // Filter by ID if provided + if len(filterIDs) > 0 { + found := false + for _, id := range filterIDs { + if id == f.FieldID { + found = true + break + } + } + if !found { + continue + } + } + + // Filter by name if provided + if fieldName != "" && f.FieldName != fieldName { + continue + } + + fields = append(fields, FieldInfo{ + FieldID: f.FieldID, + FieldName: f.FieldName, + DisplayName: f.DisplayName, + FieldType: f.FieldType, + ValueType: f.ValueType, + Options: f.Options, + DefaultValue: f.DefaultValue, + }) + } + } + + return MarshalResult(map[string]any{ + "fields": fields, + "total": len(fields), + }), nil + } +} diff --git a/pkg/flashduty/format.go b/pkg/flashduty/format.go new file mode 100644 index 0000000..5131dd3 --- /dev/null +++ b/pkg/flashduty/format.go @@ -0,0 +1,80 @@ +package flashduty + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + toon "github.com/toon-format/toon-go" +) + +// OutputFormat defines the serialization format for tool results +type OutputFormat string + +const ( + // OutputFormatJSON uses standard JSON serialization (default) + OutputFormatJSON OutputFormat = "json" + // OutputFormatTOON uses Token-Oriented Object Notation for reduced token usage + OutputFormatTOON OutputFormat = "toon" +) + +// ParseOutputFormat converts a string to OutputFormat, defaulting to JSON +func ParseOutputFormat(s string) OutputFormat { + switch strings.ToLower(strings.TrimSpace(s)) { + case "toon": + return OutputFormatTOON + default: + return OutputFormatJSON + } +} + +// String returns the string representation of OutputFormat +func (f OutputFormat) String() string { + return string(f) +} + +// outputFormat is the current output format setting (package-level for simplicity) +var outputFormat = OutputFormatJSON + +// SetOutputFormat sets the global output format +func SetOutputFormat(format OutputFormat) { + outputFormat = format +} + +// GetOutputFormat returns the current global output format +func GetOutputFormat() OutputFormat { + return outputFormat +} + +// MarshalResult serializes the given value according to the current output format +// and returns it as a text result for MCP tool response. +func MarshalResult(v any) *mcp.CallToolResult { + return MarshalResultWithFormat(v, outputFormat) +} + +// MarshalResultWithFormat serializes the given value using the specified format +func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { + var data []byte + var err error + + switch format { + case OutputFormatTOON: + data, err = toon.Marshal(v) + default: + data, err = json.Marshal(v) + } + + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)) + } + + return mcp.NewToolResultText(string(data)) +} + +// MarshalledTextResult is the original function that always uses JSON. +// Kept for backward compatibility. New code should use MarshalResult. +func MarshalledTextResult(v any) *mcp.CallToolResult { + r, _ := json.Marshal(v) + return mcp.NewToolResultText(string(r)) +} diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index a0d2afc..4db49c4 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -2,221 +2,461 @@ package flashduty import ( "context" + "encoding/json" "fmt" "net/http" + "strconv" "strings" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "golang.org/x/sync/errgroup" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) -// IncidentInfos creates a tool to get incident information by incident IDs -func IncidentInfos(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_incidents_infos", - mcp.WithDescription(t("TOOL_FLASHDUTY_INCIDENTS_INFOS_DESCRIPTION", "Get incident information by incident IDs")), +const defaultQueryLimit = 20 + +const queryIncidentsDescription = `Query incidents with comprehensive filters and return enriched data. + +Returns complete incident information including: +- Responder names and emails +- Channel name +- Creator/Closer names +- Alerts preview (first 20 alerts with total count, enabled by default) + +**Use cases:** +- Query active incidents: progress="Triggered,Processing" +- Query by ID: incident_ids="id1,id2" +- Query by channel: channel_id=123 +- Query by severity: severity="Critical" +- Query by time range: start_time, end_time + +**Parameters:** +- incident_ids (optional): Comma-separated incident IDs for direct lookup +- progress (optional): Filter by status - Triggered, Processing, Closed +- severity (optional): Filter by severity - Info, Warning, Critical +- channel_id (optional): Filter by collaboration space ID +- start_time, end_time (optional): Unix timestamp range (required if no incident_ids) +- title (optional): Title keyword search +- limit (optional): Max results (default 20) +- include_alerts (optional): Include alerts preview (default true) + +**Notes:** +- Returns enriched data with human-readable names +- Use query_incident_timeline for detailed timeline events +- Use query_incident_alerts for more alerts` + +// QueryIncidents creates a tool to query incidents with enriched data +func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_incidents", + mcp.WithDescription(t("TOOL_QUERY_INCIDENTS_DESCRIPTION", queryIncidentsDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_INCIDENTS_INFOS_USER_TITLE", "Get incident infos"), + Title: t("TOOL_QUERY_INCIDENTS_USER_TITLE", "Query incidents"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("incident_ids", - mcp.Required(), - mcp.Description("Comma-separated list of incident IDs to get information for. Example: 'id1,id2,id3'"), - ), + mcp.WithString("incident_ids", mcp.Description("Comma-separated incident IDs to query directly")), + mcp.WithString("progress", mcp.Description("Filter by progress: Triggered, Processing, Closed (comma-separated for multiple)")), + mcp.WithString("severity", mcp.Description("Filter by severity: Info, Warning, Critical")), + mcp.WithNumber("channel_id", mcp.Description("Filter by collaboration space ID")), + mcp.WithNumber("start_time", mcp.Description("Start time (Unix timestamp)")), + mcp.WithNumber("end_time", mcp.Description("End time (Unix timestamp)")), + mcp.WithString("title", mcp.Description("Title keyword search")), + mcp.WithNumber("limit", mcp.Description("Max results (default 20)")), + mcp.WithBoolean("include_alerts", mcp.Description("Include alerts preview (default true)")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Extract incident_ids from request - incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + ctx, client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - // Parse comma-separated string to string slice - var incidentIds []string - if incidentIdsStr != "" { - parts := strings.Split(incidentIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - incidentIds = append(incidentIds, part) - } - } - } + // Extract parameters + incidentIdsStr, _ := OptionalParam[string](request, "incident_ids") + progress, _ := OptionalParam[string](request, "progress") + severity, _ := OptionalParam[string](request, "severity") + channelID, _ := OptionalInt(request, "channel_id") + startTime, _ := OptionalInt(request, "start_time") + endTime, _ := OptionalInt(request, "end_time") + title, _ := OptionalParam[string](request, "title") + limit, _ := OptionalInt(request, "limit") - if len(incidentIds) == 0 { - return mcp.NewToolResultError("incident_ids cannot be empty"), nil + // Default include_alerts to true if not explicitly set to false + includeAlerts := true + if v, ok := request.GetArguments()["include_alerts"].(bool); ok { + includeAlerts = v } - // Get Flashduty client - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + if limit <= 0 { + limit = defaultQueryLimit } - // Build request body according to API specification - requestBody := map[string]interface{}{ - "incident_ids": incidentIds, + var rawIncidents []RawIncident + + // Query by IDs or by filters + if incidentIdsStr != "" { + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID when specified"), nil + } + rawIncidents, err = client.fetchIncidentsByIDs(ctx, incidentIDs) + } else { + if startTime == 0 || endTime == 0 { + return mcp.NewToolResultError("Both start_time and end_time are required for time-based queries"), nil + } + rawIncidents, err = client.fetchIncidentsByFilters(ctx, progress, severity, channelID, startTime, endTime, title, limit) } - // Make API request to /incident/list-by-ids endpoint - resp, err := client.makeRequest(ctx, "POST", "/incident/list-by-ids", requestBody) if err != nil { - return nil, fmt.Errorf("failed to get incident infos: %w", err) + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve incidents: %v", err)), nil } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("API request failed with status %d", resp.StatusCode)), nil + if len(rawIncidents) == 0 { + return MarshalResult(map[string]any{ + "incidents": []EnrichedIncident{}, + "total": 0, + }), nil } - // Parse response - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + // Enrich incidents with person/channel names + enrichedIncidents, err := client.enrichIncidents(ctx, rawIncidents) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to load additional incident details: %v", err)), nil } - // Check for API error - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + // Fetch alerts concurrently if requested + if includeAlerts && len(enrichedIncidents) > 0 { + g, gctx := errgroup.WithContext(ctx) + for i := range enrichedIncidents { + i := i + incidentID := enrichedIncidents[i].IncidentID + g.Go(func() error { + alerts, total, err := client.fetchIncidentAlerts(gctx, incidentID, defaultQueryLimit) + if err != nil { + return err + } + enrichedIncidents[i].AlertsPreview = alerts + enrichedIncidents[i].AlertsTotal = total + return nil + }) + } + if err := g.Wait(); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil + } } - return MarshalledTextResult(result.Data), nil + return MarshalResult(map[string]any{ + "incidents": enrichedIncidents, + "total": len(enrichedIncidents), + }), nil } } -// ListIncidents creates a tool to list incidents with comprehensive filters -func ListIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_list_incidents", - mcp.WithDescription(t("TOOL_FLASHDUTY_LIST_INCIDENTS_DESCRIPTION", "List incidents with comprehensive filters")), +const queryIncidentTimelineDescription = `Query timeline events for one or more incidents. + +Returns detailed timeline with enriched operator names and event details. + +**Parameters:** +- incident_ids (required): Comma-separated incident IDs to query timeline for + +**Event types include:** +- i_new: Incident created +- i_assign: Incident assigned +- i_ack: Incident acknowledged +- i_rslv: Incident resolved +- i_notify: Notification sent +- i_comm: Comment added +- i_r_*: Field updates (title, description, severity, etc.) + +**Returns:** +- Timeline events with operator names and enriched details` + +// QueryIncidentTimeline creates a tool to query incident timeline +func QueryIncidentTimeline(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_incident_timeline", + mcp.WithDescription(t("TOOL_QUERY_INCIDENT_TIMELINE_DESCRIPTION", queryIncidentTimelineDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_LIST_INCIDENTS_USER_TITLE", "List incidents"), + Title: t("TOOL_QUERY_INCIDENT_TIMELINE_USER_TITLE", "Query incident timeline"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithNumber("p", mcp.Description("Page number (default: 1)")), - mcp.WithNumber("limit", mcp.Description("Items per page (default: 20)")), - mcp.WithString("title", mcp.Description("Search by incident title")), - mcp.WithNumber("team_id", mcp.Description("Filter by team ID")), - mcp.WithString("progress", mcp.Description("Filter by progress status (Triggered, Processing, Closed)")), - mcp.WithNumber("start_time", mcp.Required(), mcp.Description("Start time (Unix timestamp, required)")), - mcp.WithNumber("end_time", mcp.Required(), mcp.Description("End time (Unix timestamp, required)")), - mcp.WithString("incident_severity", mcp.Description("Filter by severity (Info, Warning, Critical)")), - mcp.WithNumber("channel_id", mcp.Description("Filter by channel ID")), + mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated incident IDs")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Get pagination parameters - p, err := OptionalInt(request, "p") + ctx, client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if p == 0 { - p = 1 + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - limit, err := OptionalInt(request, "limit") + incidentIdsStr, err := RequiredParam[string](request, "incident_ids") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - if limit == 0 { - limit = 20 + + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - // Get filter parameters - title, _ := OptionalParam[string](request, "title") - teamID, _ := OptionalInt(request, "team_id") - progress, _ := OptionalParam[string](request, "progress") - severity, _ := OptionalParam[string](request, "incident_severity") - channelID, _ := OptionalInt(request, "channel_id") + // Fetch all timelines concurrently + type timelineResult struct { + IncidentID string + Items []RawTimelineItem + } + results := make([]timelineResult, len(incidentIDs)) + allPersonIDs := make([]int64, 0) - // Get required time parameters - startTime, err := RequiredInt(request, "start_time") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + g, gctx := errgroup.WithContext(ctx) + for i, id := range incidentIDs { + i, id := i, id + g.Go(func() error { + items, err := client.fetchIncidentTimeline(gctx, id) + if err != nil { + return err + } + results[i] = timelineResult{IncidentID: id, Items: items} + return nil + }) + } + + if err := g.Wait(); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve timeline: %v", err)), nil + } + + // Collect all person IDs from all timelines + for _, r := range results { + allPersonIDs = append(allPersonIDs, collectTimelinePersonIDs(r.Items)...) } - endTime, err := RequiredInt(request, "end_time") + // Batch fetch person info (use original ctx, not errgroup's ctx) + personMap, err := client.fetchPersonInfos(ctx, allPersonIDs) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return mcp.NewToolResultError(fmt.Sprintf("Unable to load person details: %v", err)), nil + } + + // Build enriched response + response := make([]map[string]any, 0, len(results)) + for _, r := range results { + enrichedEvents := enrichTimelineItems(r.Items, personMap) + response = append(response, map[string]any{ + "incident_id": r.IncidentID, + "timeline": enrichedEvents, + "total": len(enrichedEvents), + }) } + return MarshalResult(map[string]any{ + "results": response, + }), nil + } +} + +const queryIncidentAlertsDescription = `Query alerts for one or more incidents. + +**Parameters:** +- incident_ids (required): Comma-separated incident IDs to query alerts for +- limit (optional): Max alerts per incident (default 20) + +**Returns:** +- Alerts with title, severity, status, and labels` + +// QueryIncidentAlerts creates a tool to query incident alerts +func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_incident_alerts", + mcp.WithDescription(t("TOOL_QUERY_INCIDENT_ALERTS_DESCRIPTION", queryIncidentAlertsDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_INCIDENT_ALERTS_USER_TITLE", "Query incident alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated incident IDs")), + mcp.WithNumber("limit", mcp.Description("Max alerts per incident (default 20)")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ctx, client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - // Build request body with required time parameters - requestBody := map[string]interface{}{ - "p": p, - "limit": limit, - "start_time": startTime, - "end_time": endTime, - } - if title != "" { - requestBody["title"] = title - } - if teamID > 0 { - requestBody["team_id"] = teamID - } - if progress != "" { - requestBody["progress"] = progress + incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - if severity != "" { - requestBody["incident_severity"] = severity + + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - if channelID > 0 { - requestBody["channel_id"] = channelID + + limit, _ := OptionalInt(request, "limit") + if limit <= 0 { + limit = defaultQueryLimit } - resp, err := client.makeRequest(ctx, "POST", "/incident/list", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to list incidents: %w", err) + // Fetch all alerts concurrently + type alertsResult struct { + IncidentID string + Alerts []AlertPreview + Total int } + results := make([]alertsResult, len(incidentIDs)) - defer func() { _ = resp.Body.Close() }() + g, gctx := errgroup.WithContext(ctx) + for i, id := range incidentIDs { + i, id := i, id + g.Go(func() error { + alerts, total, err := client.fetchIncidentAlerts(gctx, id, limit) + if err != nil { + return err + } + results[i] = alertsResult{IncidentID: id, Alerts: alerts, Total: total} + return nil + }) + } - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + if err := g.Wait(); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + // Build response + response := make([]map[string]any, 0, len(results)) + for _, r := range results { + response = append(response, map[string]any{ + "incident_id": r.IncidentID, + "alerts": r.Alerts, + "total": r.Total, + }) } - return MarshalledTextResult(result.Data), nil + return MarshalResult(map[string]any{ + "results": response, + }), nil } } +// fetchIncidentsByIDs fetches incidents by their IDs +func (c *Client) fetchIncidentsByIDs(ctx context.Context, incidentIDs []string) ([]RawIncident, error) { + requestBody := map[string]interface{}{ + "incident_ids": incidentIDs, + } + + resp, err := c.makeRequest(ctx, "POST", "/incident/list-by-ids", requestBody) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []RawIncident `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + if result.Data == nil { + return nil, nil + } + return result.Data.Items, nil +} + +// fetchIncidentsByFilters fetches incidents by filters +func (c *Client) fetchIncidentsByFilters(ctx context.Context, progress, severity string, channelID int, startTime, endTime int, title string, limit int) ([]RawIncident, error) { + requestBody := map[string]interface{}{ + "p": 1, + "limit": limit, + "start_time": startTime, + "end_time": endTime, + } + + if progress != "" { + requestBody["progress"] = progress + } + if severity != "" { + requestBody["incident_severity"] = severity + } + if channelID > 0 { + requestBody["channel_id"] = channelID + } + if title != "" { + requestBody["title"] = title + } + + resp, err := c.makeRequest(ctx, "POST", "/incident/list", requestBody) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with HTTP status %d", resp.StatusCode) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []RawIncident `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + if result.Data == nil { + return nil, nil + } + return result.Data.Items, nil +} + +const createIncidentDescription = `Create a new incident in Flashduty. + +**Parameters:** +- title (required): Incident title +- severity (required): Info, Warning, or Critical +- channel_id (optional): Collaboration space ID +- description (optional): Incident description +- assigned_to (optional): Comma-separated person IDs to assign + +**Returns:** +- Created incident ID and details` + // CreateIncident creates a tool to create a new incident func CreateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_create_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_CREATE_INCIDENT_DESCRIPTION", "Create a new incident")), + return mcp.NewTool("create_incident", + mcp.WithDescription(t("TOOL_CREATE_INCIDENT_DESCRIPTION", createIncidentDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_CREATE_INCIDENT_USER_TITLE", "Create incident"), + Title: t("TOOL_CREATE_INCIDENT_USER_TITLE", "Create incident"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("title", mcp.Required(), mcp.Description("The title of the incident")), - mcp.WithString("incident_severity", mcp.Required(), mcp.Description("The severity level (Info, Warning, Critical)")), - mcp.WithNumber("channel_id", mcp.Description("The ID of the collaboration space (optional)")), - mcp.WithString("description", mcp.Description("The description of the incident")), - mcp.WithString("impact", mcp.Description("The impact of the incident")), + mcp.WithString("title", mcp.Required(), mcp.Description("Incident title")), + mcp.WithString("severity", mcp.Required(), mcp.Description("Severity: Info, Warning, Critical")), + mcp.WithNumber("channel_id", mcp.Description("Collaboration space ID")), + mcp.WithString("description", mcp.Description("Incident description")), + mcp.WithString("assigned_to", mcp.Description("Comma-separated person IDs to assign")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + title, err := RequiredParam[string](request, "title") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - severity, err := RequiredParam[string](request, "incident_severity") + severity, err := RequiredParam[string](request, "severity") if err != nil { return mcp.NewToolResultError(err.Error()), nil } channelID, _ := OptionalInt(request, "channel_id") description, _ := OptionalParam[string](request, "description") - impact, _ := OptionalParam[string](request, "impact") - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } + assignedTo, _ := OptionalParam[string](request, "assigned_to") requestBody := map[string]interface{}{ "title": title, @@ -228,1132 +468,454 @@ func CreateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe if description != "" { requestBody["description"] = description } - if impact != "" { - requestBody["impact"] = impact + if assignedTo != "" { + personIDs := parseCommaSeparatedInts(assignedTo) + if len(personIDs) > 0 { + requestBody["assigned_to"] = map[string]interface{}{ + "type": "assign", + "person_ids": personIDs, + } + } } resp, err := client.makeRequest(ctx, "POST", "/incident/create", requestBody) if err != nil { return nil, fmt.Errorf("failed to create incident: %w", err) } - defer func() { _ = resp.Body.Close() }() var result FlashdutyResponse if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(result.Data), nil + return MarshalResult(result.Data), nil } } -// AckIncident creates a tool to acknowledge incidents -func AckIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_ack_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_ACK_INCIDENT_DESCRIPTION", "Acknowledge incidents")), +const updateIncidentDescription = `Update an existing incident. + +**Parameters:** +- incident_id (required): Incident ID to update +- title (optional): New title +- description (optional): New description +- severity (optional): New severity (Info, Warning, Critical) +- custom_fields (optional): JSON object of custom field updates, e.g. {"field_name": "value"} + +**Notes:** +- Only provided fields will be updated +- Use query_fields to discover available custom fields` + +// UpdateIncident creates a tool to update an incident +func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_incident", + mcp.WithDescription(t("TOOL_UPDATE_INCIDENT_DESCRIPTION", updateIncidentDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_ACK_INCIDENT_USER_TITLE", "Acknowledge incident"), + Title: t("TOOL_UPDATE_INCIDENT_USER_TITLE", "Update incident"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("incident_ids", - mcp.Required(), - mcp.Description("Comma-separated list of incident IDs to acknowledge. Example: 'id1,id2,id3'"), - ), + mcp.WithString("incident_id", mcp.Required(), mcp.Description("Incident ID to update")), + mcp.WithString("title", mcp.Description("New title")), + mcp.WithString("description", mcp.Description("New description")), + mcp.WithString("severity", mcp.Description("New severity: Info, Warning, Critical")), + mcp.WithString("custom_fields", mcp.Description("JSON object of custom field updates, e.g. {\"field_name\": \"value\"}")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + incidentID, err := RequiredParam[string](request, "incident_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Parse comma-separated string to string slice - var incidentIds []string - if incidentIdsStr != "" { - parts := strings.Split(incidentIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - incidentIds = append(incidentIds, part) - } + title, _ := OptionalParam[string](request, "title") + description, _ := OptionalParam[string](request, "description") + severity, _ := OptionalParam[string](request, "severity") + customFieldsStr, _ := OptionalParam[string](request, "custom_fields") + + updatedFields := make([]string, 0) + + // Update title + if title != "" { + if err := client.updateIncidentField(ctx, incidentID, "/incident/title/reset", "title", title); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update title: %v", err)), nil } + updatedFields = append(updatedFields, "title") } - if len(incidentIds) == 0 { - return mcp.NewToolResultError("incident_ids cannot be empty"), nil + // Update description + if description != "" { + if err := client.updateIncidentField(ctx, incidentID, "/incident/description/reset", "description", description); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update description: %v", err)), nil + } + updatedFields = append(updatedFields, "description") } - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + // Update severity + if severity != "" { + if err := client.updateIncidentField(ctx, incidentID, "/incident/severity/reset", "incident_severity", severity); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update severity: %v", err)), nil + } + updatedFields = append(updatedFields, "severity") } - requestBody := map[string]interface{}{ - "incident_ids": incidentIds, - } + // Update custom fields + if customFieldsStr != "" { + customFieldsStr = strings.TrimSpace(customFieldsStr) + if customFieldsStr == "" { + return mcp.NewToolResultError("custom_fields must be a valid JSON object, not empty"), nil + } - resp, err := client.makeRequest(ctx, "POST", "/incident/ack", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to acknowledge incident: %w", err) - } + var customFields map[string]any + if err := json.Unmarshal([]byte(customFieldsStr), &customFields); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("custom_fields must be a valid JSON object: %v", err)), nil + } - defer func() { _ = resp.Body.Close() }() + if len(customFields) == 0 { + return mcp.NewToolResultError("custom_fields must contain at least one field"), nil + } - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + // Validate field names (alphanumeric and underscore only) + for fieldName := range customFields { + if fieldName == "" { + return mcp.NewToolResultError("custom_fields contains an empty field name"), nil + } + for _, c := range fieldName { + isValid := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' + if !isValid { + return mcp.NewToolResultError(fmt.Sprintf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", fieldName)), nil + } + } + } + + for fieldName, fieldValue := range customFields { + if err := client.updateCustomField(ctx, incidentID, fieldName, fieldValue); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update custom field '%s': %v", fieldName, err)), nil + } + updatedFields = append(updatedFields, fieldName) + } } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + if len(updatedFields) == 0 { + return mcp.NewToolResultError("No fields specified to update"), nil } - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incidents acknowledged successfully"}), nil + return MarshalResult(map[string]any{ + "status": "success", + "message": "Incident updated successfully", + "updated_fields": updatedFields, + }), nil } } -// ResolveIncident creates a tool to resolve incidents -func ResolveIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_resolve_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_RESOLVE_INCIDENT_DESCRIPTION", "Resolve incidents")), +// updateIncidentField is a helper to update a single incident field +func (c *Client) updateIncidentField(ctx context.Context, incidentID, endpoint, fieldName, fieldValue string) error { + requestBody := map[string]interface{}{ + "incident_id": incidentID, + fieldName: fieldValue, + } + + resp, err := c.makeRequest(ctx, "POST", endpoint, requestBody) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed with HTTP status %d", resp.StatusCode) + } + + var result FlashdutyResponse + if err := parseResponse(resp, &result); err != nil { + return err + } + if result.Error != nil { + return fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + return nil +} + +// updateCustomField is a helper to update a custom field +func (c *Client) updateCustomField(ctx context.Context, incidentID, fieldName string, fieldValue any) error { + requestBody := map[string]interface{}{ + "incident_id": incidentID, + "field_name": fieldName, + "field_value": fieldValue, + } + + resp, err := c.makeRequest(ctx, "POST", "/incident/field/reset", requestBody) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed with HTTP status %d", resp.StatusCode) + } + + var result FlashdutyResponse + if err := parseResponse(resp, &result); err != nil { + return err + } + if result.Error != nil { + return fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) + } + return nil +} + +const ackIncidentDescription = `Acknowledge one or more incidents. + +**Parameters:** +- incident_ids (required): Comma-separated incident IDs to acknowledge + +**Notes:** +- Moves incidents from Triggered to Processing status +- Records the acknowledging user in timeline` + +// AckIncident creates a tool to acknowledge incidents +func AckIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("ack_incident", + mcp.WithDescription(t("TOOL_ACK_INCIDENT_DESCRIPTION", ackIncidentDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_RESOLVE_INCIDENT_USER_TITLE", "Resolve incident"), + Title: t("TOOL_ACK_INCIDENT_USER_TITLE", "Acknowledge incident"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("incident_ids", - mcp.Required(), - mcp.Description("Comma-separated list of incident IDs to resolve. Example: 'id1,id2,id3'"), - ), - mcp.WithString("root_cause", mcp.Description("Root cause of the incidents")), - mcp.WithString("resolution", mcp.Description("Resolution description")), + mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated incident IDs to acknowledge")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + ctx, client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - rootCause, _ := OptionalParam[string](request, "root_cause") - resolution, _ := OptionalParam[string](request, "resolution") - - // Parse comma-separated string to string slice - var incidentIds []string - if incidentIdsStr != "" { - parts := strings.Split(incidentIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - incidentIds = append(incidentIds, part) - } - } + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - if len(incidentIds) == 0 { - return mcp.NewToolResultError("incident_ids cannot be empty"), nil + incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } requestBody := map[string]interface{}{ - "incident_ids": incidentIds, - } - if rootCause != "" { - requestBody["root_cause"] = rootCause - } - if resolution != "" { - requestBody["resolution"] = resolution + "incident_ids": incidentIDs, } - resp, err := client.makeRequest(ctx, "POST", "/incident/resolve", requestBody) + resp, err := client.makeRequest(ctx, "POST", "/incident/ack", requestBody) if err != nil { - return nil, fmt.Errorf("failed to resolve incident: %w", err) + return nil, fmt.Errorf("unable to acknowledge incidents: %w", err) } - defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil + } + var result FlashdutyResponse if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incidents resolved successfully"}), nil + return MarshalResult(map[string]string{ + "status": "success", + "message": fmt.Sprintf("%d incident(s) acknowledged", len(incidentIDs)), + }), nil } } -// ListPastIncidents creates a tool to list similar historical incidents -func ListPastIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_list_past_incidents", - mcp.WithDescription(t("TOOL_FLASHDUTY_LIST_PAST_INCIDENTS_DESCRIPTION", "List similar historical incidents")), +const closeIncidentDescription = `Close (resolve) one or more incidents. + +**Parameters:** +- incident_ids (required): Comma-separated incident IDs to close + +**Notes:** +- Moves incidents to Closed status +- Records the closing user in timeline` + +// CloseIncident creates a tool to close incidents +func CloseIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("close_incident", + mcp.WithDescription(t("TOOL_CLOSE_INCIDENT_DESCRIPTION", closeIncidentDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_LIST_PAST_INCIDENTS_USER_TITLE", "List past incidents"), - ReadOnlyHint: ToBoolPtr(true), + Title: t("TOOL_CLOSE_INCIDENT_USER_TITLE", "Close incident"), + ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to find similar historical incidents for")), - mcp.WithNumber("p", mcp.Description("Page number (default: 1)")), - mcp.WithNumber("limit", mcp.Description("Items per page (default: 20)")), + mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated incident IDs to close")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") + ctx, client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - p, _ := OptionalInt(request, "p") - if p == 0 { - p = 1 + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) } - limit, _ := OptionalInt(request, "limit") - if limit == 0 { - limit = 20 + incidentIdsStr, err := RequiredParam[string](request, "incident_ids") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } requestBody := map[string]interface{}{ - "incident_id": incidentID, - "p": p, - "limit": limit, + "incident_ids": incidentIDs, } - resp, err := client.makeRequest(ctx, "POST", "/incident/past/list", requestBody) + resp, err := client.makeRequest(ctx, "POST", "/incident/resolve", requestBody) if err != nil { - return nil, fmt.Errorf("failed to list past incidents: %w", err) + return nil, fmt.Errorf("unable to close incidents: %w", err) } - defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil + } + var result FlashdutyResponse if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(result.Data), nil + return MarshalResult(map[string]string{ + "status": "success", + "message": fmt.Sprintf("%d incident(s) closed", len(incidentIDs)), + }), nil } } -// GetIncidentTimeline creates a tool to get incident timeline and feed -func GetIncidentTimeline(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_get_incident_timeline", - mcp.WithDescription(t("TOOL_FLASHDUTY_GET_INCIDENT_TIMELINE_DESCRIPTION", "Get incident timeline and feed")), +const listSimilarIncidentsDescription = `Find similar historical incidents for a given incident. + +**Parameters:** +- incident_id (required): Reference incident ID to find similar incidents +- limit (optional): Max results (default 20) + +**Use cases:** +- Find past incidents with similar patterns +- Review historical resolutions for guidance +- Identify recurring issues + +**Returns:** +- List of similar incidents with enriched data` + +// ListSimilarIncidents creates a tool to find similar incidents +func ListSimilarIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_similar_incidents", + mcp.WithDescription(t("TOOL_LIST_SIMILAR_INCIDENTS_DESCRIPTION", listSimilarIncidentsDescription)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_GET_INCIDENT_TIMELINE_USER_TITLE", "Get incident timeline"), + Title: t("TOOL_LIST_SIMILAR_INCIDENTS_USER_TITLE", "List similar incidents"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to get timeline for")), - mcp.WithString("types", mcp.Description("Comma-separated list of operation record types to filter (e.g., 'i_comm,i_notify,i_ack')")), - mcp.WithNumber("p", mcp.Description("Page number (default: 1)")), - mcp.WithNumber("limit", mcp.Description("Items per page (default: 20)")), - mcp.WithBoolean("asc", mcp.Description("Whether to sort in ascending order (default: true)")), + mcp.WithString("incident_id", mcp.Required(), mcp.Description("Reference incident ID")), + mcp.WithNumber("limit", mcp.Description("Max results (default 20)")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + incidentID, err := RequiredParam[string](request, "incident_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - typesStr, _ := OptionalParam[string](request, "types") - p, _ := OptionalInt(request, "p") - if p == 0 { - p = 1 - } limit, _ := OptionalInt(request, "limit") - if limit == 0 { - limit = 20 - } - asc, _ := OptionalParam[bool](request, "asc") - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + if limit <= 0 { + limit = defaultQueryLimit } requestBody := map[string]interface{}{ "incident_id": incidentID, - "p": p, + "p": 1, "limit": limit, - "asc": asc, - } - - if typesStr != "" { - parts := strings.Split(typesStr, ",") - var types []string - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - types = append(types, part) - } - } - if len(types) > 0 { - requestBody["types"] = types - } } - resp, err := client.makeRequest(ctx, "POST", "/incident/feed", requestBody) + resp, err := client.makeRequest(ctx, "POST", "/incident/past/list", requestBody) if err != nil { - return nil, fmt.Errorf("failed to get incident timeline: %w", err) + return nil, fmt.Errorf("unable to find similar incidents: %w", err) } - defer func() { _ = resp.Body.Close() }() - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil } + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []RawIncident `json:"items"` + Total int `json:"total"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } if result.Error != nil { return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil } - return MarshalledTextResult(result.Data), nil + if result.Data == nil || len(result.Data.Items) == 0 { + return MarshalResult(map[string]any{ + "incidents": []EnrichedIncident{}, + "total": 0, + }), nil + } + + // Enrich similar incidents + enrichedIncidents, err := client.enrichIncidents(ctx, result.Data.Items) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to load additional incident details: %v", err)), nil + } + + return MarshalResult(map[string]any{ + "incidents": enrichedIncidents, + "total": result.Data.Total, + }), nil } } -// GetIncidentAlerts creates a tool to get alerts associated with incidents -func GetIncidentAlerts(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_get_incident_alerts", - mcp.WithDescription(t("TOOL_FLASHDUTY_GET_INCIDENT_ALERTS_DESCRIPTION", "Get alerts associated with incidents")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_GET_INCIDENT_ALERTS_USER_TITLE", "Get incident alerts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to get alerts for")), - mcp.WithNumber("p", mcp.Description("Page number (default: 1)")), - mcp.WithNumber("limit", mcp.Description("Items per page (default: 20)")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - p, _ := OptionalInt(request, "p") - if p == 0 { - p = 1 - } - limit, _ := OptionalInt(request, "limit") - if limit == 0 { - limit = 20 - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "p": p, - "limit": limit, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/alert/list", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to get incident alerts: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(result.Data), nil - } -} - -// AssignIncident creates a tool to assign incidents to people or escalation rules -func AssignIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_assign_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_ASSIGN_INCIDENT_DESCRIPTION", "Assign incidents to people or escalation rules")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_ASSIGN_INCIDENT_USER_TITLE", "Assign incident"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to assign")), - mcp.WithString("person_ids", mcp.Description("Comma-separated list of person IDs to assign to (use this OR escalate_rule_id)")), - mcp.WithString("escalate_rule_id", mcp.Description("Escalation rule ID to assign to (use this OR person_ids)")), - mcp.WithString("escalate_rule_name", mcp.Description("Escalation rule name")), - mcp.WithNumber("layer_idx", mcp.Description("Layer index when assigning to an escalation rule")), - mcp.WithString("type", mcp.Description("Assignment type: assign, reassign, escalate, reopen (default: assign)")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - personIdsStr, _ := OptionalParam[string](request, "person_ids") - escalateRuleID, _ := OptionalParam[string](request, "escalate_rule_id") - escalateRuleName, _ := OptionalParam[string](request, "escalate_rule_name") - layerIdx, _ := OptionalInt(request, "layer_idx") - assignType, _ := OptionalParam[string](request, "type") - - if assignType == "" { - assignType = "assign" - } - - if personIdsStr == "" && escalateRuleID == "" { - return mcp.NewToolResultError("Either person_ids or escalate_rule_id must be provided"), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - assignedTo := map[string]interface{}{ - "type": assignType, - } - - if personIdsStr != "" { - parts := strings.Split(personIdsStr, ",") - var personIds []int - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - var id int - if _, parseErr := fmt.Sscanf(part, "%d", &id); parseErr == nil { - personIds = append(personIds, id) - } - } - } - if len(personIds) > 0 { - assignedTo["person_ids"] = personIds - } - } - - if escalateRuleID != "" { - assignedTo["escalate_rule_id"] = escalateRuleID - if escalateRuleName != "" { - assignedTo["escalate_rule_name"] = escalateRuleName - } - if layerIdx > 0 { - assignedTo["layer_idx"] = layerIdx - } - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "assigned_to": assignedTo, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/assign", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to assign incident: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident assigned successfully"}), nil - } -} - -// AddResponder creates a tool to add responders to incidents -func AddResponder(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_add_responder", - mcp.WithDescription(t("TOOL_FLASHDUTY_ADD_RESPONDER_DESCRIPTION", "Add responders to incidents")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_ADD_RESPONDER_USER_TITLE", "Add responder"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to add responders to")), - mcp.WithString("person_ids", mcp.Required(), mcp.Description("Comma-separated list of person IDs to add as responders")), - mcp.WithBoolean("follow_preference", mcp.Description("Whether to follow personal notification preferences (default: true)")), - mcp.WithString("personal_channels", mcp.Description("Comma-separated list of personal notification channels (email, sms, voice)")), - mcp.WithString("template_id", mcp.Description("Notification template ID")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - personIdsStr, err := RequiredParam[string](request, "person_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - followPreference, _ := OptionalParam[bool](request, "follow_preference") - personalChannelsStr, _ := OptionalParam[string](request, "personal_channels") - templateID, _ := OptionalParam[string](request, "template_id") - - // Parse person IDs - parts := strings.Split(personIdsStr, ",") - var personIds []int - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - var id int - if _, parseErr := fmt.Sscanf(part, "%d", &id); parseErr == nil { - personIds = append(personIds, id) - } - } - } - - if len(personIds) == 0 { - return mcp.NewToolResultError("person_ids cannot be empty"), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "person_ids": personIds, - } - - // Add notification settings if provided - if followPreference || personalChannelsStr != "" || templateID != "" { - notify := map[string]interface{}{} - if followPreference { - notify["follow_preference"] = true - } - if personalChannelsStr != "" { - channels := strings.Split(personalChannelsStr, ",") - var personalChannels []string - for _, ch := range channels { - ch = strings.TrimSpace(ch) - if ch != "" { - personalChannels = append(personalChannels, ch) - } - } - if len(personalChannels) > 0 { - notify["personal_channels"] = personalChannels - } - } - if templateID != "" { - notify["template_id"] = templateID - } - if len(notify) > 0 { - requestBody["notify"] = notify - } - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/responder/add", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to add responder: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Responder added successfully"}), nil - } -} - -// SnoozeIncident creates a tool to snooze incidents for a period -func SnoozeIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_snooze_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_SNOOZE_INCIDENT_DESCRIPTION", "Snooze incidents for a period")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_SNOOZE_INCIDENT_USER_TITLE", "Snooze incident"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated list of incident IDs to snooze")), - mcp.WithNumber("minutes", mcp.Required(), mcp.Description("Number of minutes to snooze (1-1440)")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentIdsStr, err := RequiredParam[string](request, "incident_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - minutes, err := RequiredParam[float64](request, "minutes") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - if minutes < 1 || minutes > 1440 { - return mcp.NewToolResultError("minutes must be between 1 and 1440"), nil - } - - // Parse incident IDs - parts := strings.Split(incidentIdsStr, ",") - var incidentIds []string - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - incidentIds = append(incidentIds, part) - } - } - - if len(incidentIds) == 0 { - return mcp.NewToolResultError("incident_ids cannot be empty"), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_ids": incidentIds, - "minutes": int(minutes), - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/snooze", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to snooze incident: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incidents snoozed successfully"}), nil - } -} - -// MergeIncident creates a tool to merge multiple incidents into one -func MergeIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_merge_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_MERGE_INCIDENT_DESCRIPTION", "Merge multiple incidents into one")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_MERGE_INCIDENT_USER_TITLE", "Merge incident"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("source_incident_ids", mcp.Required(), mcp.Description("Comma-separated list of source incident IDs to merge")), - mcp.WithString("target_incident_id", mcp.Required(), mcp.Description("Target incident ID to merge into")), - mcp.WithString("title", mcp.Description("New title for the merged incident")), - mcp.WithString("comment", mcp.Description("Comment for the merge operation")), - mcp.WithBoolean("remove_source_incidents", mcp.Required(), mcp.Description("Whether to remove source incidents after merge")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - sourceIncidentIdsStr, err := RequiredParam[string](request, "source_incident_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - targetIncidentID, err := RequiredParam[string](request, "target_incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - title, _ := OptionalParam[string](request, "title") - comment, _ := OptionalParam[string](request, "comment") - removeSource, err := RequiredParam[bool](request, "remove_source_incidents") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Parse source incident IDs - parts := strings.Split(sourceIncidentIdsStr, ",") - var sourceIncidentIds []string - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - sourceIncidentIds = append(sourceIncidentIds, part) - } - } - - if len(sourceIncidentIds) == 0 { - return mcp.NewToolResultError("source_incident_ids cannot be empty"), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "source_incident_ids": sourceIncidentIds, - "target_incident_id": targetIncidentID, - "remove_source_incidents": removeSource, - } - - if title != "" { - requestBody["title"] = title - } - if comment != "" { - requestBody["comment"] = comment - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/merge", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to merge incident: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incidents merged successfully"}), nil - } -} - -// CommentIncident creates a tool to add comments to incidents -func CommentIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_comment_incident", - mcp.WithDescription(t("TOOL_FLASHDUTY_COMMENT_INCIDENT_DESCRIPTION", "Add comments to incidents")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_COMMENT_INCIDENT_USER_TITLE", "Comment incident"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated list of incident IDs to comment on")), - mcp.WithString("comment", mcp.Required(), mcp.Description("The comment to add to the incidents")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentIdsStr, err := RequiredParam[string](request, "incident_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - comment, err := RequiredParam[string](request, "comment") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Parse incident IDs - parts := strings.Split(incidentIdsStr, ",") - var incidentIds []string - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - incidentIds = append(incidentIds, part) - } - } - - if len(incidentIds) == 0 { - return mcp.NewToolResultError("incident_ids cannot be empty"), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_ids": incidentIds, - "comment": comment, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/comment", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to comment incident: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Comment added successfully"}), nil - } -} - -// UpdateIncidentTitle creates a tool to update incident title -func UpdateIncidentTitle(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_title", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_TITLE_DESCRIPTION", "Update incident title")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_TITLE_USER_TITLE", "Update incident title"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("title", mcp.Required(), mcp.Description("The new title for the incident")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - title, err := RequiredParam[string](request, "title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "title": title, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/title/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident title: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident title updated successfully"}), nil - } -} - -// UpdateIncidentDescription creates a tool to update incident description -func UpdateIncidentDescription(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_description", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_DESCRIPTION_DESCRIPTION", "Update incident description")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_DESCRIPTION_USER_TITLE", "Update incident description"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("description", mcp.Required(), mcp.Description("The new description for the incident")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - description, err := RequiredParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "description": description, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/description/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident description: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident description updated successfully"}), nil - } -} - -// UpdateIncidentImpact creates a tool to update incident impact -func UpdateIncidentImpact(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_impact", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_IMPACT_DESCRIPTION", "Update incident impact")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_IMPACT_USER_TITLE", "Update incident impact"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("impact", mcp.Required(), mcp.Description("The new impact description for the incident")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - impact, err := RequiredParam[string](request, "impact") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "impact": impact, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/impact/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident impact: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident impact updated successfully"}), nil - } -} - -// UpdateIncidentRootCause creates a tool to update incident root cause -func UpdateIncidentRootCause(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_root_cause", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_ROOT_CAUSE_DESCRIPTION", "Update incident root cause")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_ROOT_CAUSE_USER_TITLE", "Update incident root cause"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("root_cause", mcp.Required(), mcp.Description("The new root cause description for the incident")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - rootCause, err := RequiredParam[string](request, "root_cause") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "root_cause": rootCause, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/root-cause/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident root cause: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident root cause updated successfully"}), nil - } -} - -// UpdateIncidentResolution creates a tool to update incident resolution -func UpdateIncidentResolution(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_resolution", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_RESOLUTION_DESCRIPTION", "Update incident resolution")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_RESOLUTION_USER_TITLE", "Update incident resolution"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("resolution", mcp.Required(), mcp.Description("The new resolution description for the incident")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - resolution, err := RequiredParam[string](request, "resolution") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "resolution": resolution, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/resolution/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident resolution: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident resolution updated successfully"}), nil +// Helper functions + +func parseCommaSeparatedStrings(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) } + } + return result } -// UpdateIncidentSeverity creates a tool to update incident severity -func UpdateIncidentSeverity(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_severity", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_SEVERITY_DESCRIPTION", "Update incident severity")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_SEVERITY_USER_TITLE", "Update incident severity"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("severity", mcp.Required(), mcp.Description("The new severity level for the incident (e.g., Info, Warning, Critical)")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - severity, err := RequiredParam[string](request, "severity") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "severity": severity, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/severity/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident severity: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident severity updated successfully"}), nil +func parseCommaSeparatedInts(s string) []int { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue } -} - -// UpdateIncidentFields creates a tool to update custom fields -func UpdateIncidentFields(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_update_incident_fields", - mcp.WithDescription(t("TOOL_FLASHDUTY_UPDATE_INCIDENT_FIELDS_DESCRIPTION", "Update custom fields")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_UPDATE_INCIDENT_FIELDS_USER_TITLE", "Update incident fields"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update")), - mcp.WithString("field_name", mcp.Required(), mcp.Description("The name of the custom field to update")), - mcp.WithString("field_value", mcp.Required(), mcp.Description("The new value for the custom field")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - incidentID, err := RequiredParam[string](request, "incident_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - fieldName, err := RequiredParam[string](request, "field_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - fieldValue, err := RequiredParam[string](request, "field_value") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "field_name": fieldName, - "field_value": fieldValue, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/field/reset", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to update incident fields: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(map[string]string{"status": "success", "message": "Incident fields updated successfully"}), nil + id, err := strconv.Atoi(part) + if err == nil { + result = append(result, id) } + } + return result } diff --git a/pkg/flashduty/incidents_test.go b/pkg/flashduty/incidents_test.go deleted file mode 100644 index 505273b..0000000 --- a/pkg/flashduty/incidents_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package flashduty - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestListIncidents(t *testing.T) { - responses := map[string]interface{}{ - "/incident/list": map[string]interface{}{ - "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"incident_id": 1, "incident_title": "Database Connection Issue"}, - map[string]interface{}{"incident_id": 2, "incident_title": "API Response Time High"}, - }, - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := ListIncidents(getClient, translator) - assert.Equal(t, "flashduty_list_incidents", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "start_time": 1640995200.0, // 2022-01-01 00:00:00 UTC - "end_time": 1672531200.0, // 2023-01-01 00:00:00 UTC - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "Database Connection Issue") - assert.Contains(t, textResult.Text, "API Response Time High") -} - -func TestIncidentInfos(t *testing.T) { - responses := map[string]interface{}{ - "/incident/list-by-ids": map[string]interface{}{ - "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"incident_id": "1", "incident_title": "Database Connection Issue", "status": "triggered"}, - map[string]interface{}{"incident_id": "2", "incident_title": "API Response Time High", "status": "acknowledged"}, - }, - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := IncidentInfos(getClient, translator) - assert.Equal(t, "flashduty_incidents_infos", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "incident_ids": "1,2", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "Database Connection Issue") - assert.Contains(t, textResult.Text, "triggered") -} - -func TestCreateIncident(t *testing.T) { - responses := map[string]interface{}{ - "/incident/create": map[string]interface{}{ - "data": map[string]interface{}{ - "incident_id": 3, "incident_title": "New Critical Issue", - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := CreateIncident(getClient, translator) - assert.Equal(t, "flashduty_create_incident", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "title": "New Critical Issue", - "incident_severity": "Critical", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "New Critical Issue") -} - -func TestAckIncident(t *testing.T) { - responses := map[string]interface{}{ - "/incident/ack": map[string]interface{}{ - "data": map[string]interface{}{ - "incident_id": 1, "status": "acknowledged", - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := AckIncident(getClient, translator) - assert.Equal(t, "flashduty_ack_incident", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "incident_ids": "1", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "success") -} - -func TestResolveIncident(t *testing.T) { - responses := map[string]interface{}{ - "/incident/resolve": map[string]interface{}{ - "data": map[string]interface{}{ - "incident_id": 1, "status": "resolved", - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := ResolveIncident(getClient, translator) - assert.Equal(t, "flashduty_resolve_incident", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "incident_ids": "1", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "success") -} diff --git a/pkg/flashduty/members.go b/pkg/flashduty/members.go deleted file mode 100644 index 6ea7193..0000000 --- a/pkg/flashduty/members.go +++ /dev/null @@ -1,90 +0,0 @@ -package flashduty - -import ( - "context" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// MemberInfos creates a tool to get member information by person IDs -func MemberInfos(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_member_infos", - mcp.WithDescription(t("TOOL_FLASHDUTY_MEMBER_INFOS_DESCRIPTION", "Get member information by person IDs")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_MEMBER_INFOS_USER_TITLE", "Get member infos"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("person_ids", - mcp.Required(), - mcp.Description("Comma-separated list of person IDs to get information for. Persons can be accounts or members. Example: '123,456,789'"), - ), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Extract person_ids from request - personIdsStr, err := RequiredParam[string](request, "person_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Parse comma-separated string to int slice - var personIdsInt []int - if personIdsStr != "" { - parts := strings.Split(personIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - id, err := strconv.Atoi(part) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid person_id: %s", part)), nil - } - personIdsInt = append(personIdsInt, id) - } - } - } - - if len(personIdsInt) == 0 { - return mcp.NewToolResultError("person_ids cannot be empty"), nil - } - - // Get Flashduty client - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - // Build request body according to API specification - requestBody := map[string]interface{}{ - "person_ids": personIdsInt, - } - - // Make API request to /person/infos endpoint - resp, err := client.makeRequest(ctx, "POST", "/person/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to get member infos: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("API request failed with status %d", resp.StatusCode)), nil - } - - // Parse response - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Check for API error - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(result.Data), nil - } -} diff --git a/pkg/flashduty/members_test.go b/pkg/flashduty/members_test.go deleted file mode 100644 index 74ef597..0000000 --- a/pkg/flashduty/members_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package flashduty - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// createMCPRequest is a helper function to create a MCP request with the given arguments. -func createMCPRequest(args any) mcp.CallToolRequest { - return mcp.CallToolRequest{ - Params: struct { - Name string `json:"name"` - Arguments any `json:"arguments,omitempty"` - Meta *mcp.Meta `json:"_meta,omitempty"` - }{ - Arguments: args, - }, - } -} - -// getTextResult is a helper function that returns a text result from a tool call. -func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent { - t.Helper() - assert.NotNil(t, result) - require.Len(t, result.Content, 1) - require.IsType(t, mcp.TextContent{}, result.Content[0]) - textContent := result.Content[0].(mcp.TextContent) - assert.Equal(t, "text", textContent.Type) - return textContent -} - -// testServer creates a mock Flashduty API server for testing -func testServer(t *testing.T, responses map[string]interface{}) *httptest.Server { - t.Helper() - - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if a response is configured for the given path - if response, exists := responses[r.URL.Path]; exists { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(response) - if err != nil { - t.Fatalf("Failed to write mock response: %v", err) - } - return - } - - // Check if an error is configured - if errorResponse, exists := responses["error:"+r.URL.Path]; exists { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - err := json.NewEncoder(w).Encode(errorResponse) - if err != nil { - t.Fatalf("Failed to write mock error response: %v", err) - } - return - } - - // Default to not found - http.NotFound(w, r) - })) -} - -// testTranslationHelper is a simple translation helper for testing -func testTranslationHelper(key, defaultValue string) string { - return defaultValue -} - -// testSetup provides common test setup for Flashduty tests -func testSetup(t *testing.T, responses map[string]interface{}) (GetFlashdutyClientFn, translations.TranslationHelperFunc) { - t.Helper() - - // Create a test server - server := testServer(t, responses) - t.Cleanup(server.Close) // Ensure the server is closed after the test - - // Create a client that points to the test server - getClient := func(ctx context.Context) (context.Context, *Client, error) { - client, err := NewClient("test-app-key", server.URL, "test-agent") - if err != nil { - return nil, nil, err - } - return ctx, client, nil - } - - translator := testTranslationHelper - - return getClient, translator -} - -func TestMemberInfos(t *testing.T) { - responses := map[string]interface{}{ - "/person/infos": map[string]interface{}{ - "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"person_id": 1, "person_name": "John Doe", "avatar": "https://example.com/avatar1.png", "as": "member"}, - map[string]interface{}{"person_id": 2, "person_name": "Jane Smith", "avatar": "https://example.com/avatar2.png", "as": "account"}, - }, - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := MemberInfos(getClient, translator) - assert.Equal(t, "flashduty_member_infos", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "person_ids": "1,2", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "John Doe") - assert.Contains(t, textResult.Text, "Jane Smith") -} diff --git a/pkg/flashduty/server.go b/pkg/flashduty/server.go index f29526f..cead868 100644 --- a/pkg/flashduty/server.go +++ b/pkg/flashduty/server.go @@ -1,7 +1,6 @@ package flashduty import ( - "encoding/json" "fmt" "github.com/mark3labs/mcp-go/mcp" @@ -78,12 +77,6 @@ func OptionalInt(r mcp.CallToolRequest, p string) (int, error) { return int(v), nil } -// MarshalledTextResult marshals the given value to JSON and returns it as a text result -func MarshalledTextResult(v any) *mcp.CallToolResult { - r, _ := json.Marshal(v) - return mcp.NewToolResultText(string(r)) -} - // ToBoolPtr converts a bool to a *bool pointer. func ToBoolPtr(b bool) *bool { return &b diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go new file mode 100644 index 0000000..88f9f74 --- /dev/null +++ b/pkg/flashduty/statuspage.go @@ -0,0 +1,432 @@ +package flashduty + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +const queryStatusPagesDescription = `Query status pages with full configuration. + +**Parameters:** +- page_ids (optional): Comma-separated page IDs for direct lookup. If not provided, lists all pages. + +**Returns:** +- Status pages with sections, components, and overall status` + +// QueryStatusPages creates a tool to query status pages +func QueryStatusPages(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_status_pages", + mcp.WithDescription(t("TOOL_QUERY_STATUS_PAGES_DESCRIPTION", queryStatusPagesDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_STATUS_PAGES_USER_TITLE", "Query status pages"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("page_ids", mcp.Description("Comma-separated status page IDs")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + pageIdsStr, _ := OptionalParam[string](request, "page_ids") + + // List all pages first + resp, err := client.makeRequest(ctx, "GET", "/status-page/list", nil) + if err != nil { + return nil, fmt.Errorf("failed to list status pages: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API failed with status %d", resp.StatusCode)), nil + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + PageID int64 `json:"page_id"` + PageName string `json:"name"` + URLName string `json:"url_name,omitempty"` + Description string `json:"description,omitempty"` + Components []struct { + ComponentID string `json:"component_id"` + Name string `json:"name"` + } `json:"components,omitempty"` + } `json:"items"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + if result.Data == nil || len(result.Data.Items) == 0 { + return MarshalResult(map[string]any{ + "pages": []StatusPage{}, + "total": 0, + }), nil + } + + // Filter by page_ids if provided + var pageIDs []int64 + if pageIdsStr != "" { + for _, id := range parseCommaSeparatedInts(pageIdsStr) { + pageIDs = append(pageIDs, int64(id)) + } + } + + pages := make([]StatusPage, 0) + for _, item := range result.Data.Items { + // Skip if filtering and not in list + if len(pageIDs) > 0 { + found := false + for _, id := range pageIDs { + if id == item.PageID { + found = true + break + } + } + if !found { + continue + } + } + + page := StatusPage{ + PageID: item.PageID, + PageName: item.PageName, + Slug: item.URLName, + Description: item.Description, + } + + // Convert components and calculate overall status + worstStatus := "operational" + if len(item.Components) > 0 { + page.Components = make([]StatusComponent, 0, len(item.Components)) + for _, comp := range item.Components { + page.Components = append(page.Components, StatusComponent{ + ComponentID: comp.ComponentID, + ComponentName: comp.Name, + Status: "operational", // Default status + }) + } + } + page.OverallStatus = worstStatus + + pages = append(pages, page) + } + + return MarshalResult(map[string]any{ + "pages": pages, + "total": len(pages), + }), nil + } +} + +const listStatusChangesDescription = `List active change events (incidents/maintenances) on a status page. + +**Parameters:** +- page_id (required): Status page ID +- type (required): Change type - "incident" or "maintenance" + +**Returns:** +- List of active change events (non-resolved/completed)` + +// ListStatusChanges creates a tool to list status page changes +func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_status_changes", + mcp.WithDescription(t("TOOL_LIST_STATUS_CHANGES_DESCRIPTION", listStatusChangesDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_STATUS_CHANGES_USER_TITLE", "List status changes"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithNumber("page_id", mcp.Required(), mcp.Description("Status page ID")), + mcp.WithString("type", mcp.Required(), mcp.Description("Change type: incident or maintenance")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + pageID, err := RequiredInt(request, "page_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + changeType, err := RequiredParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + if changeType != "incident" && changeType != "maintenance" { + return mcp.NewToolResultError("type must be 'incident' or 'maintenance'"), nil + } + + // Use GET for active list endpoint + resp, err := client.makeRequest(ctx, "GET", fmt.Sprintf("/status-page/change/active/list?page_id=%d&type=%s", pageID, changeType), nil) + if err != nil { + return nil, fmt.Errorf("failed to list status changes: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API failed with status %d", resp.StatusCode)), nil + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []StatusChange `json:"items"` + Total int `json:"total"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + changes := []StatusChange{} + total := 0 + if result.Data != nil { + changes = result.Data.Items + total = result.Data.Total + } + + return MarshalResult(map[string]any{ + "changes": changes, + "total": total, + }), nil + } +} + +const createStatusIncidentDescription = `Create a new incident on a status page. + +**Parameters:** +- page_id (required): Status page ID +- title (required): Incident title +- message (optional): Initial update message describing the incident +- status (optional): Status - investigating, identified, monitoring, resolved (default: investigating) +- affected_components (optional): Comma-separated component IDs with status, format: "id1:degraded,id2:partial_outage" + - Component statuses: operational, degraded, partial_outage, full_outage +- notify_subscribers (optional): Whether to notify subscribers (default: true) + +**Returns:** +- Created change event ID` + +// CreateStatusIncident creates a tool to create status page incident +func CreateStatusIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_status_incident", + mcp.WithDescription(t("TOOL_CREATE_STATUS_INCIDENT_DESCRIPTION", createStatusIncidentDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_STATUS_INCIDENT_USER_TITLE", "Create status incident"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithNumber("page_id", mcp.Required(), mcp.Description("Status page ID")), + mcp.WithString("title", mcp.Required(), mcp.Description("Incident title")), + mcp.WithString("message", mcp.Description("Initial update message")), + mcp.WithString("status", mcp.Description("Status: investigating, identified, monitoring, resolved")), + mcp.WithString("affected_components", mcp.Description("Component IDs with status: id1:degraded,id2:partial_outage")), + mcp.WithBoolean("notify_subscribers", mcp.Description("Notify subscribers (default: true)")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + pageID, err := RequiredInt(request, "page_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + title, err := RequiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + message, _ := OptionalParam[string](request, "message") + status, _ := OptionalParam[string](request, "status") + affectedComponents, _ := OptionalParam[string](request, "affected_components") + notifySubscribers, _ := OptionalParam[bool](request, "notify_subscribers") + + if status == "" { + status = "investigating" + } + + // Build the initial update + update := map[string]interface{}{ + "at_seconds": time.Now().Unix(), + "status": status, + } + if message != "" { + update["description"] = message + } + + // Parse component changes if provided (format: "id1:status1,id2:status2") + if affectedComponents != "" { + var componentChanges []map[string]string + parts := parseCommaSeparatedStrings(affectedComponents) + for _, part := range parts { + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + componentChanges = append(componentChanges, map[string]string{ + "component_id": strings.TrimSpace(kv[0]), + "status": strings.TrimSpace(kv[1]), + }) + } else if len(kv) == 1 && kv[0] != "" { + // Default to partial_outage if no status specified + componentChanges = append(componentChanges, map[string]string{ + "component_id": strings.TrimSpace(kv[0]), + "status": "partial_outage", + }) + } + } + if len(componentChanges) > 0 { + update["component_changes"] = componentChanges + } + } + + // Use message as both change description and first update description + description := message + if description == "" { + description = title // Fallback to title if no message provided + } + + requestBody := map[string]interface{}{ + "page_id": pageID, + "title": title, + "type": "incident", + "status": status, + "description": description, + "updates": []map[string]interface{}{update}, + } + + // Default notify_subscribers to true + requestBody["notify_subscribers"] = notifySubscribers + + resp, err := client.makeRequest(ctx, "POST", "/status-page/change/create", requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create status incident: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result FlashdutyResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + return MarshalResult(result.Data), nil + } +} + +const createChangeTimelineDescription = `Add a timeline update to a status page change event. + +**Parameters:** +- page_id (required): Status page ID +- change_id (required): Change event ID to update +- message (required): Update message describing the change +- at (optional): Timestamp for the update (Unix timestamp, default: now) +- status (optional): New status - investigating, identified, monitoring, resolved +- component_changes (optional): JSON array of component status changes, e.g. [{"component_id":"xxx","status":"degraded"}] + +**Use cases:** +- Post investigation updates +- Mark incident as resolved +- Update affected components + +**Returns:** +- Created timeline entry ID` + +// CreateChangeTimeline creates a tool to add timeline entry to status change +func CreateChangeTimeline(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_change_timeline", + mcp.WithDescription(t("TOOL_CREATE_CHANGE_TIMELINE_DESCRIPTION", createChangeTimelineDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_CHANGE_TIMELINE_USER_TITLE", "Create change timeline"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithNumber("page_id", mcp.Required(), mcp.Description("Status page ID")), + mcp.WithNumber("change_id", mcp.Required(), mcp.Description("Change event ID")), + mcp.WithString("message", mcp.Required(), mcp.Description("Update message (required)")), + mcp.WithNumber("at", mcp.Description("Timestamp (Unix timestamp, default: now)")), + mcp.WithString("status", mcp.Description("New status: investigating, identified, monitoring, resolved")), + mcp.WithString("component_changes", mcp.Description("JSON array: [{\"component_id\":\"xxx\",\"status\":\"degraded\"}]")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + pageID, err := RequiredInt(request, "page_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + changeID, err := RequiredInt(request, "change_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + message, err := RequiredParam[string](request, "message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + at, _ := OptionalInt(request, "at") + status, _ := OptionalParam[string](request, "status") + componentChanges, _ := OptionalParam[string](request, "component_changes") + + requestBody := map[string]interface{}{ + "page_id": pageID, + "change_id": changeID, + "description": message, + } + if at > 0 { + requestBody["at_seconds"] = at + } + if status != "" { + requestBody["status"] = status + } + if componentChanges != "" { + // Parse JSON array if provided + var changes []map[string]string + if err := json.Unmarshal([]byte(componentChanges), &changes); err == nil { + requestBody["component_changes"] = changes + } + } + + resp, err := client.makeRequest(ctx, "POST", "/status-page/change/timeline/create", requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create timeline: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result FlashdutyResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + return MarshalResult(map[string]string{ + "status": "success", + "message": "Timeline entry created", + }), nil + } +} diff --git a/pkg/flashduty/teams.go b/pkg/flashduty/teams.go deleted file mode 100644 index ed6d664..0000000 --- a/pkg/flashduty/teams.go +++ /dev/null @@ -1,91 +0,0 @@ -// This is a new file -package flashduty - -import ( - "context" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// TeamInfos creates a tool to get team information by team IDs -func TeamInfos(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("flashduty_teams_infos", - mcp.WithDescription(t("TOOL_FLASHDUTY_TEAMS_INFOS_DESCRIPTION", "Get team information by team IDs")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FLASHDUTY_TEAMS_INFOS_USER_TITLE", "Get team infos"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("team_ids", - mcp.Required(), - mcp.Description("Comma-separated list of team IDs to get information for. Example: '123,456,789'"), - ), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Extract team_ids from request - teamIdsStr, err := RequiredParam[string](request, "team_ids") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Parse comma-separated string to int slice - var teamIdsInt []int - if teamIdsStr != "" { - parts := strings.Split(teamIdsStr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - id, err := strconv.Atoi(part) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid team_id: %s", part)), nil - } - teamIdsInt = append(teamIdsInt, id) - } - } - } - - if len(teamIdsInt) == 0 { - return mcp.NewToolResultError("team_ids cannot be empty"), nil - } - - // Get Flashduty client - ctx, client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Flashduty client: %w", err) - } - - // Build request body according to API specification - requestBody := map[string]interface{}{ - "team_ids": teamIdsInt, - } - - // Make API request to /team/infos endpoint - resp, err := client.makeRequest(ctx, "POST", "/team/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to get team infos: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("API request failed with status %d", resp.StatusCode)), nil - } - - // Parse response - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Check for API error - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - return MarshalledTextResult(result.Data), nil - } -} diff --git a/pkg/flashduty/teams_test.go b/pkg/flashduty/teams_test.go deleted file mode 100644 index 2a68e61..0000000 --- a/pkg/flashduty/teams_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// This is a new file -package flashduty - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTeamInfos(t *testing.T) { - responses := map[string]interface{}{ - "/team/infos": map[string]interface{}{ - "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"team_id": 1, "team_name": "Engineering Team", "person_ids": []interface{}{1, 2, 3}}, - map[string]interface{}{"team_id": 2, "team_name": "Operations Team", "person_ids": []interface{}{4, 5}}, - }, - }, - }, - } - getClient, translator := testSetup(t, responses) - - tool, handler := TeamInfos(getClient, translator) - assert.Equal(t, "flashduty_teams_infos", tool.Name) - - request := createMCPRequest(map[string]interface{}{ - "team_ids": "1,2", - }) - ctx := context.Background() - result, err := handler(ctx, request) - - assert.NoError(t, err) - textResult := getTextResult(t, result) - assert.Contains(t, textResult.Text, "Engineering Team") - assert.Contains(t, textResult.Text, "Operations Team") -} diff --git a/pkg/flashduty/tools.go b/pkg/flashduty/tools.go index b539a7b..f459b84 100644 --- a/pkg/flashduty/tools.go +++ b/pkg/flashduty/tools.go @@ -6,60 +6,69 @@ import ( ) // DefaultTools is the default list of enabled Flashduty toolsets -var DefaultTools = []string{"flashduty_members", "flashduty_teams", "flashduty_channels", "flashduty_incidents"} +var DefaultTools = []string{"incidents", "changes", "status_page", "users", "channels", "fields"} // DefaultToolsetGroup returns the default toolset group for Flashduty func DefaultToolsetGroup(getClient GetFlashdutyClientFn, readOnly bool, t translations.TranslationHelperFunc) *toolsets.ToolsetGroup { group := toolsets.NewToolsetGroup(readOnly) - // Members toolset - members := toolsets.NewToolset("flashduty_members", "Flashduty member management tools"). + // Incidents toolset (8 tools) + incidents := toolsets.NewToolset("incidents", "Incident lifecycle management tools"). AddReadTools( - toolsets.NewServerTool(MemberInfos(getClient, t)), + toolsets.NewServerTool(QueryIncidents(getClient, t)), + toolsets.NewServerTool(QueryIncidentTimeline(getClient, t)), + toolsets.NewServerTool(QueryIncidentAlerts(getClient, t)), + toolsets.NewServerTool(ListSimilarIncidents(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateIncident(getClient, t)), + toolsets.NewServerTool(UpdateIncident(getClient, t)), + toolsets.NewServerTool(AckIncident(getClient, t)), + toolsets.NewServerTool(CloseIncident(getClient, t)), ) - group.AddToolset(members) + group.AddToolset(incidents) - // Teams toolset - teams := toolsets.NewToolset("flashduty_teams", "Flashduty team management tools"). + // Changes toolset (1 tool) + changes := toolsets.NewToolset("changes", "Change record query tools"). AddReadTools( - toolsets.NewServerTool(TeamInfos(getClient, t)), + toolsets.NewServerTool(QueryChanges(getClient, t)), ) - group.AddToolset(teams) + group.AddToolset(changes) - // Channels toolset - channels := toolsets.NewToolset("flashduty_channels", "Flashduty collaboration channel management tools"). + // Status Page toolset (4 tools) + statusPage := toolsets.NewToolset("status_page", "Status page management tools"). AddReadTools( - toolsets.NewServerTool(ChannelInfos(getClient, t)), + toolsets.NewServerTool(QueryStatusPages(getClient, t)), + toolsets.NewServerTool(ListStatusChanges(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateStatusIncident(getClient, t)), + toolsets.NewServerTool(CreateChangeTimeline(getClient, t)), ) - group.AddToolset(channels) + group.AddToolset(statusPage) - // Incidents toolset - incidents := toolsets.NewToolset("flashduty_incidents", "Flashduty incident management tools"). + // Users toolset (2 tools) + users := toolsets.NewToolset("users", "Member and team query tools"). AddReadTools( - toolsets.NewServerTool(IncidentInfos(getClient, t)), - toolsets.NewServerTool(ListIncidents(getClient, t)), - toolsets.NewServerTool(ListPastIncidents(getClient, t)), - toolsets.NewServerTool(GetIncidentTimeline(getClient, t)), - toolsets.NewServerTool(GetIncidentAlerts(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(CreateIncident(getClient, t)), - toolsets.NewServerTool(AckIncident(getClient, t)), - toolsets.NewServerTool(ResolveIncident(getClient, t)), - toolsets.NewServerTool(AssignIncident(getClient, t)), - toolsets.NewServerTool(AddResponder(getClient, t)), - toolsets.NewServerTool(SnoozeIncident(getClient, t)), - toolsets.NewServerTool(MergeIncident(getClient, t)), - toolsets.NewServerTool(CommentIncident(getClient, t)), - toolsets.NewServerTool(UpdateIncidentTitle(getClient, t)), - toolsets.NewServerTool(UpdateIncidentDescription(getClient, t)), - toolsets.NewServerTool(UpdateIncidentImpact(getClient, t)), - toolsets.NewServerTool(UpdateIncidentRootCause(getClient, t)), - toolsets.NewServerTool(UpdateIncidentResolution(getClient, t)), - toolsets.NewServerTool(UpdateIncidentSeverity(getClient, t)), - toolsets.NewServerTool(UpdateIncidentFields(getClient, t)), + toolsets.NewServerTool(QueryMembers(getClient, t)), + toolsets.NewServerTool(QueryTeams(getClient, t)), ) - group.AddToolset(incidents) + group.AddToolset(users) + + // Channels toolset (2 tools) + channelsToolset := toolsets.NewToolset("channels", "Collaboration space and escalation rule tools"). + AddReadTools( + toolsets.NewServerTool(QueryChannels(getClient, t)), + toolsets.NewServerTool(QueryEscalationRules(getClient, t)), + ) + group.AddToolset(channelsToolset) + + // Fields toolset (1 tool) + fields := toolsets.NewToolset("fields", "Custom field definition query tools"). + AddReadTools( + toolsets.NewServerTool(QueryFields(getClient, t)), + ) + group.AddToolset(fields) return group } diff --git a/pkg/flashduty/types.go b/pkg/flashduty/types.go new file mode 100644 index 0000000..f7bd006 --- /dev/null +++ b/pkg/flashduty/types.go @@ -0,0 +1,201 @@ +package flashduty + +// EnrichedIncident contains full incident data with human-readable names +type EnrichedIncident struct { + // Basic fields + IncidentID string `json:"incident_id" toon:"incident_id"` + Title string `json:"title" toon:"title"` + Description string `json:"description,omitempty" toon:"description,omitempty"` + Severity string `json:"severity" toon:"severity"` + Progress string `json:"progress" toon:"progress"` + + // Time fields + StartTime int64 `json:"start_time" toon:"start_time"` + AckTime int64 `json:"ack_time,omitempty" toon:"ack_time,omitempty"` + CloseTime int64 `json:"close_time,omitempty" toon:"close_time,omitempty"` + + // Channel (enriched) + ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` + ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` + + // Creator (enriched) + CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` + CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` + CreatorEmail string `json:"creator_email,omitempty" toon:"creator_email,omitempty"` + + // Closer (enriched) + CloserID int64 `json:"closer_id,omitempty" toon:"closer_id,omitempty"` + CloserName string `json:"closer_name,omitempty" toon:"closer_name,omitempty"` + + // Responders (enriched) + Responders []EnrichedResponder `json:"responders,omitempty" toon:"responders,omitempty"` + + // Timeline (full) + Timeline []TimelineEvent `json:"timeline,omitempty" toon:"timeline,omitempty"` + + // Alerts (preview) + AlertsPreview []AlertPreview `json:"alerts_preview,omitempty" toon:"alerts_preview,omitempty"` + AlertsTotal int `json:"alerts_total" toon:"alerts_total"` + + // Other + Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` + CustomFields map[string]any `json:"custom_fields,omitempty" toon:"custom_fields,omitempty"` +} + +// EnrichedResponder contains responder info with human-readable names +type EnrichedResponder struct { + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name" toon:"person_name"` + Email string `json:"email,omitempty" toon:"email,omitempty"` + AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` + AcknowledgedAt int64 `json:"acknowledged_at,omitempty" toon:"acknowledged_at,omitempty"` +} + +// TimelineEvent represents an entry in incident timeline +type TimelineEvent struct { + Type string `json:"type" toon:"type"` + Timestamp int64 `json:"timestamp" toon:"timestamp"` + OperatorID int64 `json:"operator_id,omitempty" toon:"operator_id,omitempty"` + OperatorName string `json:"operator_name,omitempty" toon:"operator_name,omitempty"` + Detail any `json:"detail,omitempty" toon:"detail,omitempty"` +} + +// AlertPreview represents a preview of an alert +type AlertPreview struct { + AlertID string `json:"alert_id" toon:"alert_id"` + Title string `json:"title" toon:"title"` + Severity string `json:"severity" toon:"severity"` + Status string `json:"status" toon:"status"` + StartTime int64 `json:"start_time" toon:"start_time"` + Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` +} + +// PersonInfo represents person information from /person/infos API +type PersonInfo struct { + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name" toon:"person_name"` + Email string `json:"email,omitempty" toon:"email,omitempty"` + Avatar string `json:"avatar,omitempty" toon:"avatar,omitempty"` + As string `json:"as,omitempty" toon:"as,omitempty"` +} + +// ChannelInfo represents channel information +type ChannelInfo struct { + ChannelID int64 `json:"channel_id" toon:"channel_id"` + ChannelName string `json:"channel_name" toon:"channel_name"` + TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty" toon:"team_name,omitempty"` +} + +// TeamInfo represents team information +type TeamInfo struct { + TeamID int64 `json:"team_id" toon:"team_id"` + TeamName string `json:"team_name" toon:"team_name"` + Members []TeamMember `json:"members,omitempty" toon:"members,omitempty"` +} + +// TeamMember represents a team member +type TeamMember struct { + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name" toon:"person_name"` + Email string `json:"email,omitempty" toon:"email,omitempty"` +} + +// FieldInfo represents custom field definition +type FieldInfo struct { + FieldID string `json:"field_id" toon:"field_id"` + FieldName string `json:"field_name" toon:"field_name"` + DisplayName string `json:"display_name" toon:"display_name"` + FieldType string `json:"field_type" toon:"field_type"` + ValueType string `json:"value_type" toon:"value_type"` + Options []string `json:"options,omitempty" toon:"options,omitempty"` + DefaultValue any `json:"default_value,omitempty" toon:"default_value,omitempty"` +} + +// EscalationRule represents an escalation rule +type EscalationRule struct { + RuleID string `json:"rule_id" toon:"rule_id"` + RuleName string `json:"rule_name" toon:"rule_name"` + Description string `json:"description,omitempty" toon:"description,omitempty"` + ChannelID int64 `json:"channel_id" toon:"channel_id"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + Layers []EscalationLayer `json:"layers,omitempty" toon:"layers,omitempty"` +} + +// EscalationLayer represents a layer in an escalation rule +type EscalationLayer struct { + LayerIdx int `json:"layer_idx" toon:"layer_idx"` + Timeout int `json:"timeout" toon:"timeout"` + NotifyInterval int `json:"notify_interval,omitempty" toon:"notify_interval,omitempty"` + MaxTimes int `json:"max_times,omitempty" toon:"max_times,omitempty"` + Targets []EscalationTarget `json:"targets,omitempty" toon:"targets,omitempty"` +} + +// EscalationTarget represents an escalation target +type EscalationTarget struct { + Type string `json:"type" toon:"type"` + ID int64 `json:"id" toon:"id"` + Name string `json:"name,omitempty" toon:"name,omitempty"` +} + +// StatusPage represents a status page +type StatusPage struct { + PageID int64 `json:"page_id" toon:"page_id"` + PageName string `json:"page_name" toon:"page_name"` + Slug string `json:"slug,omitempty" toon:"slug,omitempty"` + Description string `json:"description,omitempty" toon:"description,omitempty"` + Sections []StatusSection `json:"sections,omitempty" toon:"sections,omitempty"` + Components []StatusComponent `json:"components,omitempty" toon:"components,omitempty"` + OverallStatus string `json:"overall_status,omitempty" toon:"overall_status,omitempty"` +} + +// StatusSection represents a section in status page +type StatusSection struct { + SectionID string `json:"section_id" toon:"section_id"` + SectionName string `json:"section_name" toon:"section_name"` +} + +// StatusComponent represents a component in status page +type StatusComponent struct { + ComponentID string `json:"component_id" toon:"component_id"` + ComponentName string `json:"component_name" toon:"component_name"` + Status string `json:"status" toon:"status"` + SectionID string `json:"section_id,omitempty" toon:"section_id,omitempty"` +} + +// StatusChange represents a change event on status page +type StatusChange struct { + ChangeID int64 `json:"change_id" toon:"change_id"` + PageID int64 `json:"page_id" toon:"page_id"` + Title string `json:"title" toon:"title"` + Description string `json:"description,omitempty" toon:"description,omitempty"` + Type string `json:"type" toon:"type"` // incident or maintenance + Status string `json:"status" toon:"status"` + CreatedAt int64 `json:"created_at" toon:"created_at"` + UpdatedAt int64 `json:"updated_at,omitempty" toon:"updated_at,omitempty"` + Timelines []ChangeTimeline `json:"timelines,omitempty" toon:"timelines,omitempty"` +} + +// ChangeTimeline represents a timeline entry in status change +type ChangeTimeline struct { + TimelineID int64 `json:"timeline_id" toon:"timeline_id"` + At int64 `json:"at" toon:"at"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + Description string `json:"description,omitempty" toon:"description,omitempty"` +} + +// Change represents a change record +type Change struct { + ChangeID string `json:"change_id" toon:"change_id"` + Title string `json:"title" toon:"title"` + Description string `json:"description,omitempty" toon:"description,omitempty"` + Type string `json:"type,omitempty" toon:"type,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` + ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` + CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` + CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` + StartTime int64 `json:"start_time,omitempty" toon:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty" toon:"end_time,omitempty"` + Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` +} diff --git a/pkg/flashduty/users.go b/pkg/flashduty/users.go new file mode 100644 index 0000000..513abc4 --- /dev/null +++ b/pkg/flashduty/users.go @@ -0,0 +1,248 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +const defaultUsersQueryLimit = 20 + +const queryMembersDescription = `Query members (users) in the account. + +**Parameters:** +- person_ids (optional): Comma-separated person IDs for direct lookup +- name (optional): Search by name (fuzzy match) +- email (optional): Search by email + +**Returns:** +- Member list with ID, name, email, and team memberships` + +// QueryMembers creates a tool to query members +func QueryMembers(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_members", + mcp.WithDescription(t("TOOL_QUERY_MEMBERS_DESCRIPTION", queryMembersDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_MEMBERS_USER_TITLE", "Query members"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("person_ids", mcp.Description("Comma-separated person IDs")), + mcp.WithString("name", mcp.Description("Search by name")), + mcp.WithString("email", mcp.Description("Search by email")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + personIdsStr, _ := OptionalParam[string](request, "person_ids") + name, _ := OptionalParam[string](request, "name") + email, _ := OptionalParam[string](request, "email") + + // Query by person IDs + if personIdsStr != "" { + personIDs := parseCommaSeparatedInts(personIdsStr) + if len(personIDs) == 0 { + return mcp.NewToolResultError("person_ids must contain at least one valid ID when specified"), nil + } + + int64IDs := make([]int64, len(personIDs)) + for i, id := range personIDs { + int64IDs[i] = int64(id) + } + + personMap, err := client.fetchPersonInfos(ctx, int64IDs) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil + } + + members := make([]PersonInfo, 0, len(personMap)) + for _, p := range personMap { + members = append(members, p) + } + + return MarshalResult(map[string]any{ + "members": members, + "total": len(members), + }), nil + } + + // List all members with optional filters + requestBody := map[string]interface{}{ + "p": 1, + "limit": defaultUsersQueryLimit, + } + if name != "" { + requestBody["member_name"] = name + } + if email != "" { + requestBody["email"] = email + } + + resp, err := client.makeRequest(ctx, "POST", "/member/list", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to list members: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil + } + + var result MemberListResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + members := []MemberItem{} + total := 0 + if result.Data != nil { + members = result.Data.Items + total = result.Data.Total + } + + return MarshalResult(map[string]any{ + "members": members, + "total": total, + }), nil + } +} + +const queryTeamsDescription = `Query teams in the account. + +**Parameters:** +- team_ids (optional): Comma-separated team IDs for direct lookup +- name (optional): Search by team name + +**Returns:** +- Team list with members (names and emails)` + +// QueryTeams creates a tool to query teams +func QueryTeams(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("query_teams", + mcp.WithDescription(t("TOOL_QUERY_TEAMS_DESCRIPTION", queryTeamsDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_QUERY_TEAMS_USER_TITLE", "Query teams"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("team_ids", mcp.Description("Comma-separated team IDs")), + mcp.WithString("name", mcp.Description("Search by team name")), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + teamIdsStr, _ := OptionalParam[string](request, "team_ids") + name, _ := OptionalParam[string](request, "name") + + // Query by team IDs + if teamIdsStr != "" { + teamIDs := parseCommaSeparatedInts(teamIdsStr) + if len(teamIDs) == 0 { + return mcp.NewToolResultError("team_ids must contain at least one valid ID when specified"), nil + } + + requestBody := map[string]interface{}{ + "team_ids": teamIDs, + } + + resp, err := client.makeRequest(ctx, "POST", "/team/infos", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to retrieve teams: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil + } + + var result FlashdutyResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + return MarshalResult(result.Data), nil + } + + // List all teams + requestBody := map[string]interface{}{ + "p": 1, + "limit": defaultUsersQueryLimit, + } + if name != "" { + requestBody["team_name"] = name + } + + resp, err := client.makeRequest(ctx, "POST", "/team/list", requestBody) + if err != nil { + return nil, fmt.Errorf("unable to list teams: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("API request failed with HTTP status %d", resp.StatusCode)), nil + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *struct { + Items []struct { + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + Members []struct { + PersonID int64 `json:"person_id"` + PersonName string `json:"person_name"` + Email string `json:"email,omitempty"` + } `json:"members,omitempty"` + } `json:"items"` + Total int `json:"total"` + } `json:"data,omitempty"` + } + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + } + + teams := []TeamInfo{} + total := 0 + if result.Data != nil { + for _, t := range result.Data.Items { + team := TeamInfo{ + TeamID: t.TeamID, + TeamName: t.TeamName, + } + if len(t.Members) > 0 { + team.Members = make([]TeamMember, 0, len(t.Members)) + for _, m := range t.Members { + team.Members = append(team.Members, TeamMember{ + PersonID: m.PersonID, + PersonName: m.PersonName, + Email: m.Email, + }) + } + } + teams = append(teams, team) + } + total = result.Data.Total + } + + return MarshalResult(map[string]any{ + "teams": teams, + "total": total, + }), nil + } +} diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go deleted file mode 100644 index e6bab04..0000000 --- a/pkg/raw/raw.go +++ /dev/null @@ -1,73 +0,0 @@ -// Package raw provides a client for interacting with the GitHub raw file API -package raw - -import ( - "context" - "net/http" - "net/url" - - gogithub "github.com/google/go-github/v72/github" -) - -// GetRawClientFn is a function type that returns a RawClient instance. -type GetRawClientFn func(context.Context) (*Client, error) - -// Client is a client for interacting with the GitHub raw content API. -type Client struct { - url *url.URL - client *gogithub.Client -} - -// NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL. -func NewClient(client *gogithub.Client, rawURL *url.URL) *Client { - client = gogithub.NewClient(client.Client()) - client.BaseURL = rawURL - return &Client{client: client, url: rawURL} -} - -func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body interface{}, opts ...gogithub.RequestOption) (*http.Request, error) { - req, err := c.client.NewRequest(method, urlStr, body, opts...) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - return req, nil -} - -func (c *Client) refURL(owner, repo, ref, path string) string { - if ref == "" { - return c.url.JoinPath(owner, repo, "HEAD", path).String() - } - return c.url.JoinPath(owner, repo, ref, path).String() -} - -func (c *Client) URLFromOpts(opts *RawContentOpts, owner, repo, path string) string { - if opts == nil { - opts = &RawContentOpts{} - } - if opts.SHA != "" { - return c.commitURL(owner, repo, opts.SHA, path) - } - return c.refURL(owner, repo, opts.Ref, path) -} - -// BlobURL returns the URL for a blob in the raw content API. -func (c *Client) commitURL(owner, repo, sha, path string) string { - return c.url.JoinPath(owner, repo, sha, path).String() -} - -type RawContentOpts struct { - Ref string - SHA string -} - -// GetRawContent fetches the raw content of a file from a GitHub repository. -func (c *Client) GetRawContent(ctx context.Context, owner, repo, path string, opts *RawContentOpts) (*http.Response, error) { - url := c.URLFromOpts(opts, owner, repo, path) - req, err := c.newRequest(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - - return c.client.Client().Do(req) -} diff --git a/pkg/raw/raw_mock.go b/pkg/raw/raw_mock.go deleted file mode 100644 index 30c7759..0000000 --- a/pkg/raw/raw_mock.go +++ /dev/null @@ -1,20 +0,0 @@ -package raw - -import "github.com/migueleliasweb/go-github-mock/src/mock" - -var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/HEAD/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByBranchByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/heads/{branch}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByTagByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/tags/{tag}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoBySHAByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/{sha}/{path:.*}", - Method: "GET", -} diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go deleted file mode 100644 index bb9b23a..0000000 --- a/pkg/raw/raw_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package raw - -import ( - "context" - "net/http" - "net/url" - "testing" - - "github.com/google/go-github/v72/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/require" -) - -func TestGetRawContent(t *testing.T) { - base, _ := url.Parse("https://raw.example.com/") - - tests := []struct { - name string - pattern mock.EndpointPattern - opts *RawContentOpts - owner, repo, path string - statusCode int - contentType string - body string - expectError string - }{ - { - name: "HEAD fetch success", - pattern: GetRawReposContentsByOwnerByRepoByPath, - opts: nil, - owner: "octocat", repo: "hello", path: "README.md", - statusCode: 200, - contentType: "text/plain", - body: "# Test file", - }, - { - name: "branch fetch success", - pattern: GetRawReposContentsByOwnerByRepoByBranchByPath, - opts: &RawContentOpts{Ref: "refs/heads/main"}, - owner: "octocat", repo: "hello", path: "README.md", - statusCode: 200, - contentType: "text/plain", - body: "# Test file", - }, - { - name: "tag fetch success", - pattern: GetRawReposContentsByOwnerByRepoByTagByPath, - opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"}, - owner: "octocat", repo: "hello", path: "README.md", - statusCode: 200, - contentType: "text/plain", - body: "# Test file", - }, - { - name: "sha fetch success", - pattern: GetRawReposContentsByOwnerByRepoBySHAByPath, - opts: &RawContentOpts{SHA: "abc123"}, - owner: "octocat", repo: "hello", path: "README.md", - statusCode: 200, - contentType: "text/plain", - body: "# Test file", - }, - { - name: "not found", - pattern: GetRawReposContentsByOwnerByRepoByPath, - opts: nil, - owner: "octocat", repo: "hello", path: "notfound.txt", - statusCode: 404, - contentType: "application/json", - body: `{"message": "Not Found"}`, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - tc.pattern, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", tc.contentType) - w.WriteHeader(tc.statusCode) - _, err := w.Write([]byte(tc.body)) - require.NoError(t, err) - }), - ), - ) - ghClient := github.NewClient(mockedClient) - client := NewClient(ghClient, base) - resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts) - defer func() { - _ = resp.Body.Close() - }() - if tc.expectError != "" { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, tc.statusCode, resp.StatusCode) - }) - } -} - -func TestUrlFromOpts(t *testing.T) { - base, _ := url.Parse("https://raw.example.com/") - ghClient := github.NewClient(nil) - client := NewClient(ghClient, base) - - tests := []struct { - name string - opts *RawContentOpts - owner string - repo string - path string - want string - }{ - { - name: "no opts (HEAD)", - opts: nil, - owner: "octocat", repo: "hello", path: "README.md", - want: "https://raw.example.com/octocat/hello/HEAD/README.md", - }, - { - name: "ref branch", - opts: &RawContentOpts{Ref: "refs/heads/main"}, - owner: "octocat", repo: "hello", path: "README.md", - want: "https://raw.example.com/octocat/hello/refs/heads/main/README.md", - }, - { - name: "ref tag", - opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"}, - owner: "octocat", repo: "hello", path: "README.md", - want: "https://raw.example.com/octocat/hello/refs/tags/v1.0.0/README.md", - }, - { - name: "sha", - opts: &RawContentOpts{SHA: "abc123"}, - owner: "octocat", repo: "hello", path: "README.md", - want: "https://raw.example.com/octocat/hello/abc123/README.md", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := client.URLFromOpts(tt.opts, tt.owner, tt.repo, tt.path) - if got != tt.want { - t.Errorf("UrlFromOpts() = %q, want %q", got, tt.want) - } - }) - } -}