diff --git a/pcip/picp-6.md b/pcip/picp-6.md new file mode 100644 index 0000000..ed24360 --- /dev/null +++ b/pcip/picp-6.md @@ -0,0 +1,106 @@ +# PCIP-6: Implement the MCP for Pulsar Admin Tool + +# Background knowledge + +Apache Pulsar is a cloud-native distributed messaging and streaming platform, offering unified messaging, storage, and stream processing. It features multi-tenancy, persistent storage, and seamless scalability. Pulsar provides a command-line tool `pulsar-admin` to manage clusters, tenants, namespaces, topics, and subscriptions. However, `pulsar-admin` requires users to learn a large number of CLI commands and options, which is not user-friendly for non-experts. + +Model Context Protocol (MCP) is an open standard that defines how external tools expose capabilities to large language models (LLMs) through structured tool schemas. MCP allows LLMs like GPT or Claude to interact with systems such as Pulsar through well-defined interfaces. This is analogous to USB-C as a standardized interface for hardware devices. + +# Motivation + +Although `pulsar-admin` provides comprehensive CLI access, it's not user-friendly for non-experts. Users often struggle to express complex management needs, such as "list all subscriptions with message backlog > 1000", using raw CLI syntax. + +This proposal aims to introduce a new module that exposes Pulsar Admin functionalities as MCP-compatible tools. This allows users to manage Pulsar clusters using natural language prompts, interpreted and executed by LLMs. + +Benefits include: + +- Simplified cluster operations using plain English or other natural languages +- Multi-turn conversational interactions with context awareness +- A clean, extensible tool layer that conforms to the `pulsar-java-contrib` architecture + + +# Goals + +## In Scope + +- Implement a new module `pulsar-admin-mcp-contrib` to expose Pulsar admin functionalities via MCP protocol. +- Support 70+ commonly used admin operations via structured MCP tools. +- Provide built-in transport support for HTTP, STDIO, and SSE. +- Implement robust context tracking and parameter validation. +- Provide complete developer/user documentation and a demo use case. + +## Out of Scope + +- UI interfaces or web frontends (though it can be used as backend). +- Pulsar core changes (only builds on `pulsar-java-contrib`). +- Full support for all 120+ CLI commands (70+ are targeted for now). + +# High Level Design + +The system architecture consists of four main layers: + +1. **LLM Interface Layer**: Accepts natural language inputs from an LLM and generates MCP-compatible tool calls +2. **Protocol Layer**: Central MCP interface that dispatches structured requests to tool handlers +3. **Tool Execution Layer**: Looks up registered tools and invokes appropriate Pulsar Admin API operations +4. **Context Management Layer**: Maintains session memory, allowing parameter inheritance across steps + +Example interaction: + +User input: +> "Create a topic named `user-events` with 3 partitions" + +LLMs send structured tool calls (as per MCP schema) such as: +```json +{ + "tool": "create-topic", + "parameters": { + "name": "user-events", + "partitions": 3 + } +} +``` + +MCP executes the tool and returns a structured result, which the LLM then summarizes in natural language. +# Detailed Design + +## Design & Implementation Details + +### Package structure: +```java +pulsar-java-contrib/ + ├── MCPProtocol.java # MCP protocol interface + ├── MCPFactory.java # Factory class for dynamically loading protocol instances + ├── tools/ # Tool registration and concrete implementations + ├── client/ # PulsarAdmin client management + ├── context/ # Session state management + ├── validation/ # Parameter validation mechanism + ├── transport/ # Support for HTTP, STDIO,SSE + ├── model/ # Data structures like ToolSchema, ToolResult, etc +``` + +### Key components: +- `PulsarAdminTool`: Abstract base class for all tools (e.g. list-topics, create-tenant) +- `ToolExecutor`: Handles concurrency, thread pools, and context updates +- `ToolRegistry`: Registers all tools via Java SPI +- `SessionManager`: Tracks ongoing sessions and enhances parameters +- `ParameterValidator`: Validates tool input against ToolSchema metadata + +### Supported tools + +**Total tools**: 70+ +Grouped by category: +- **Cluster**: list-clusters, create-cluster, get-cluster-stats +- **Tenant**: list-tenants, create-tenant, delete-tenant +- **Namespace**: create-namespace, set-retention-policy, list-namespaces +- **Topic**: create-topic, delete-topic, list-topics, compact-topic, get-topic-stats +- **Subscription**: reset-subscription, get-subscription-stats +- **Message**: produce-message, peek-messages +- **Schema**: upload-schema, check-schema-compatibility +- **Monitoring**: get-cluster-performance, diagnose-topic + +Each tool includes: + +- A ToolSchema (for LLM prompt templates and validation) +- A handler (e.g. `ListTopicsHandler`) +- Parameter schema, default values, error messages + diff --git a/pom.xml b/pom.xml index e57a6e2..bd929a1 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,7 @@ pulsar-metrics-contrib pulsar-auth-contrib pulsar-rpc-contrib + pulsar-admin-mcp-contrib diff --git a/pulsar-admin-mcp-contrib/README-zh.md b/pulsar-admin-mcp-contrib/README-zh.md new file mode 100644 index 0000000..b894888 --- /dev/null +++ b/pulsar-admin-mcp-contrib/README-zh.md @@ -0,0 +1,301 @@ +# Pulsar Admin MCP Contrib + +基于 Model Context Protocol (MCP) 的 Apache Pulsar 管理服务端,支持 AI 助手通过统一接口管理 Pulsar 集群(支持 HTTP Streaming 和 STDIO 两种传输模式)。 + +## 快速开始 + +### 依赖 + +- Java 17+ +- Maven 3.6+ +- Pulsar 2.10+(3.x 优先) +- MCP Java SDK 0.12.0 +- Jetty 11.0.20 + +## 0. 启动 Pulsar + +### 方式 A: Docker +```bash +docker run -it --name pulsar -p 6650:6650 -p 8080:8080 apachepulsar/pulsar:3.2.4 bin/pulsar standalone +``` + +- **Service URL**: `pulsar://localhost:6650` +- **Admin URL**: `http://localhost:8080` + +### 方式 B: 本地二进制 +```bash +bin/pulsar standalone +``` + +## 1. 编译 Pulsar MCP + +```bash +mvn clean install -DskipTests -am -pl pulsar-admin-mcp-contrib +``` + +**输出**:`target/mcp-contrib-1.0.0-SNAPSHOT.jar` + +## 2. 启动 MCP Server + +### HTTP 模式(推荐:Web/远程) +```bash +java -jar pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar --transport http --port 8889 +``` + +**日志示例**: +``` +HTTP Streamable transport ready at http://localhost:8889/mcp/stream +``` + +**健康检查**: +```bash +curl -i http://localhost:8889/mcp/stream +``` + +### STDIO 模式(推荐:Claude Desktop / 本地 IDE) +```bash +java -jar pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar --transport stdio +``` + +## 3. 客户端配置 + +### Claude Desktop + +#### Windows 配置 + +**配置文件位置**:`%APPDATA%\Claude\claude_desktop_config.json` + +**配置步骤**: +1. 打开 Claude Desktop 配置文件 +2. 添加以下配置到 `mcpServers` 部分: + +```json +{ + "mcpServers": { + "pulsar-admin": { + "command": "java", + "args": ["-jar", "编译后的包所在的目录,例如 E:\\projects\\pulsar-admin-mcp-contrib\\target\\mcp-contrib-1.0.0-SNAPSHOT.jar", "--transport", "stdio"], + "cwd": "项目所在目录,例如E:\\projects\\pulsar-admin-mcp-contrib", + "env": { + "PULSAR_SERVICE_URL": "pulsar://localhost:6650", + "PULSAR_ADMIN_URL": "http://localhost:8080" + } + } + } +} +``` + +#### macOS 配置 + +**配置文件位置**:`~/Library/Application Support/Claude/claude_desktop_config.json` + +**配置步骤**: +1. 打开 Claude Desktop 配置文件 +2. 添加以下配置到 `mcpServers` 部分: + +```json +{ + "mcpServers": { + "pulsar-admin": { + "command": "java", + "args": ["-jar", "编译后的包所在的目录,例如 /Users/username/projects/pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar", "--transport", "stdio"], + "cwd": "项目所在目录,例如/Users/username/projects/pulsar-admin-mcp-contrib", + "env": { + "PULSAR_SERVICE_URL": "pulsar://localhost:6650", + "PULSAR_ADMIN_URL": "http://localhost:8080" + } + } + } +} +``` + +**注意事项**: +- 请将 `编译后的包所在的目录` 替换为实际的 JAR 文件路径 +- 请将 `项目所在目录` 替换为实际的项目根目录路径 +- 确保 Java 环境变量已正确配置 +- Windows 使用反斜杠 `\`,macOS 使用正斜杠 `/` + +### Cherry Studio + +#### STDIO 模式配置 +同上 + +#### HTTP 模式配置 + +**配置步骤**: +1. 确保 MCP 服务器已启动(HTTP 模式) +2. 在 Cherry Studio 中添加 HTTP 类型配置: + +```json +{ + "mcpServers": { + "pulsar-admin-http": { + "type": "http", + "url": "http://localhost:8889/mcp" + } + } +} +``` + +**配置说明**: +- **STDIO 模式**:适合本地开发,需要指定完整的 JAR 文件路径 +- **HTTP 模式**:适合远程访问,需要先启动 HTTP 服务器 +- 两种模式的环境变量配置相同,用于指定 Pulsar 集群连接信息 +- Windows 使用反斜杠 `\`,macOS 使用正斜杠 `/` + +## 4. 配置项 + +### 环境变量 +- `PULSAR_SERVICE_URL`(默认 `pulsar://localhost:6650`) +- `PULSAR_ADMIN_URL`(默认 `http://localhost:8080`) + +### 命令行参数 +- `--transport`:http / stdio +- `--port`:HTTP 端口(默认 8889) + +## 5. 工具清单 + +覆盖 **集群** / **租户** / **命名空间** / **主题** / **订阅** / **消息** / **Schema** / **监控** 8 大类,共 71 个工具: + +### 集群管理(10 个工具) +- `list-clusters` - 列出所有 Pulsar 集群及其状态 +- `get-cluster-info` - 获取特定集群的详细信息 +- `create-cluster` - 创建新的 Pulsar 集群 +- `update-cluster-config` - 更新现有集群的配置 +- `delete-cluster` - 按名称删除 Pulsar 集群 +- `get-cluster-stats` - 获取指定集群的统计信息 +- `list-brokers` - 列出集群中的所有活跃代理 +- `get-broker-stats` - 获取特定代理的统计信息 +- `get-cluster-failure-domain` - 获取集群的故障域 +- `set-cluster-failure-domain` - 设置或更新故障域配置 + +### 租户管理(6 个工具) +- `list-tenants` - 列出所有 Pulsar 租户 +- `get-tenant-info` - 获取特定租户的信息 +- `create-tenant` - 创建新的 Pulsar 租户 +- `update-tenant` - 更新租户配置 +- `delete-tenant` - 删除特定租户 +- `get-tenant-stats` - 获取租户的统计信息 + +### 命名空间管理(10 个工具) +- `list-namespaces` - 列出所有命名空间 +- `get-namespace-info` - 获取命名空间信息 +- `create-namespace` - 创建新的命名空间 +- `delete-namespace` - 删除命名空间 +- `set-retention-policy` - 为命名空间设置保留策略 +- `get-retention-policy` - 获取命名空间的保留策略 +- `set-backlog-quota` - 为命名空间设置积压配额 +- `get-backlog-quota` - 获取命名空间的积压配额 +- `clear-namespace-backlog` - 清除命名空间的积压 +- `get-namespace-stats` - 获取命名空间统计信息 + +### 主题管理(15 个工具) +- `list-topics` - 列出所有主题 +- `create-topic` - 创建新主题 +- `delete-topic` - 删除主题 +- `get-topic-stats` - 获取主题统计信息 +- `get-topic-metadata` - 获取主题元数据 +- `update-topic-partitions` - 更新主题分区数量 +- `compact-topic` - 压缩主题 +- `unload-topic` - 卸载主题 +- `get-topic-backlog` - 获取主题积压信息 +- `expire-topic-messages` - 使主题中的消息过期 +- `peek-messages` - 从主题中查看消息 +- `peek-topic-messages` - 从主题中查看消息而不消费 +- `reset-topic-cursor` - 重置主题游标 +- `get-topic-internal-stats` - 获取主题内部统计信息 +- `get-partitioned-metadata` - 获取分区主题元数据 + +### 订阅管理(10 个工具) +- `list-subscriptions` - 列出所有订阅 +- `create-subscription` - 创建新订阅 +- `delete-subscription` - 删除订阅 +- `get-subscription-stats` - 获取订阅统计信息 +- `reset-subscription-cursor` - 重置订阅游标 +- `skip-messages` - 跳过订阅中的消息 +- `expire-subscription-messages` - 使订阅中的消息过期 +- `pause-subscription` - 暂停订阅 +- `resume-subscription` - 恢复订阅 +- `unsubscribe` - 取消订阅主题 + +### 消息操作(8 个工具) +- `peek-message` - 从订阅中查看消息而不确认 +- `examine-messages` - 检查主题中的消息而不消费 +- `skip-all-messages` - 跳过订阅中的所有消息 +- `expire-all-messages` - 使订阅中的所有消息过期 +- `get-message-backlog` - 获取订阅的消息积压数量 +- `send-message` - 向主题发送消息 +- `get-message-stats` - 获取主题或订阅的消息统计信息 +- `receive-messages` - 从主题接收消息 + +### Schema 管理(6 个工具) +- `get-schema-info` - 获取主题的 Schema 信息 +- `get-schema-version` - 获取主题的 Schema 版本 +- `get-all-schema-versions` - 获取主题的所有 Schema 版本 +- `upload-schema` - 向主题上传新的 Schema +- `delete-schema` - 删除主题的 Schema +- `test-schema-compatibility` - 测试 Schema 兼容性 + +### 监控与诊断(6 个工具) +- `monitor-cluster-performance` - 监控集群性能指标 +- `monitor-topic-performance` - 监控主题性能指标 +- `monitor-subscription-performance` - 监控订阅性能 +- `health-check` - 检查集群、主题和订阅的健康状态 +- `connection-diagnostics` - 运行不同测试深度的连接诊断 +- `backlog-analysis` - 分析命名空间内的消息积压 + +> **说明**:仅初始化 PulsarAdmin 时,消息发送/消费相关会返回 `not_implemented`;要启用需初始化 PulsarClient 并创建 producer/consumer。 + +## 自然语言交互 Demo(Use Cases) + +下列示例展示在 MCP 客户端里,用自然语言触发工具调用的典型流程。实际返回字段随集群而异。 + +### 1. 租户与命名空间管理 + +**Prompt:** +> 帮我看看集群里有哪些租户;在 tenant1 下创建命名空间 ns-orders,然后把这个命名空间的统计给我看看。 + +**触发:** +`list-tenants` → `create-namespace(tenant=tenant1, namespace=ns-orders)` → `get-namespace-stats(...)` + +### 2. 创建分区主题并扩容 + +**Prompt:** +> 在 public/default 下建个主题 orders,分区数 8;然后把分区数扩到 16,给我分区元数据。 + +**触发:** +`create-topic(partitions=8)` → `update-topic-partitions(16)` → `get-partitioned-metadata` + +### 3. 清理积压并做 compaction + +**Prompt:** +> public/default/orders backlog 很大,清一下;再做一次 compaction。 + +**触发:** +`get-topic-backlog` → `expire-topic-messages/clear-namespace-backlog` → `compact-topic` + +### 4. Schema 上载与兼容性测试 + +**Prompt:** +> 给 persistent://public/default/orders 设置 Avro schema:orderId:string, amount:double,并检查兼容性。 + +**触发:** +`upload-schema(type=AVRO, schemaJson=...)` → `test-schema-compatibility` / `get-schema-info` + +### 5. 订阅管理与游标重置 + +**Prompt:** +> 在 orders 上创建 failover 订阅 sub-a,再把游标回拨到 1 小时前。 + +**触发:** +`create-subscription(type=failover)` → `reset-subscription-cursor(timestamp=now-1h)` + +## 最佳实践 + +- **Topic 命名**:完整名形如 `persistent://tenant/namespace/topic`。允许短名输入,服务端会规范化。 + +- **失败域**:为 Broker/Bookie 设置 Failure Domain,提升机架/可用区级容灾。 + +## 许可证 + +Apache License 2.0(详见 [LICENSE](../../LICENSE))。 diff --git a/pulsar-admin-mcp-contrib/README.md b/pulsar-admin-mcp-contrib/README.md new file mode 100644 index 0000000..7184440 --- /dev/null +++ b/pulsar-admin-mcp-contrib/README.md @@ -0,0 +1,308 @@ +# Pulsar Admin MCP Contrib + +Apache Pulsar management server based on Model Context Protocol (MCP), enabling AI assistants to manage Pulsar clusters through a unified interface (supports both HTTP Streaming and STDIO transport modes). + +## Demo +### Claude Desktop +![demo1](demo1.gif) + +### Cherry Studio +![demo2](demo2.gif) + +## Quick Start + +### Dependencies + +- Java 17+ +- Maven 3.6+ +- Pulsar 2.10+ (3.x preferred) +- MCP Java SDK 0.12.0 +- Jetty 11.0.20 + +## 0. Start Pulsar + +### Method A: Docker +```bash +docker run -it --name pulsar -p 6650:6650 -p 8080:8080 apachepulsar/pulsar:3.2.4 bin/pulsar standalone +``` + +- **Service URL**: `pulsar://localhost:6650` +- **Admin URL**: `http://localhost:8080` + +### Method B: Local Binary +```bash +bin/pulsar standalone +``` + +## 1. Build Pulsar-MCP + +```bash +mvn clean install -DskipTests -am -pl pulsar-admin-mcp-contrib +``` + +**Output**: `target/mcp-contrib-1.0.0-SNAPSHOT.jar` + +## 2. Start MCP Server + +### HTTP Mode (Recommended: Web/Remote) +```bash +java -jar pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar --transport http --port 8889 +``` + +**Log Example**: +``` +HTTP Streamable transport ready at http://localhost:8889/mcp/stream +``` + +**Health Check**: +```bash +curl -i http://localhost:8889/mcp/stream +``` + +### STDIO Mode (Recommended: Claude Desktop / Local IDE) +```bash +java -jar pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar --transport stdio +``` + +## 3. Client Configuration + +### Claude Desktop + +#### Windows Configuration + +**Config File Location**: `%APPDATA%\Claude\claude_desktop_config.json` + +**Configuration Steps**: +1. Open Claude Desktop config file +2. Add the following configuration to the `mcpServers` section: + +```json +{ + "mcpServers": { + "pulsar-admin": { + "command": "java", + "args": ["-jar", "Path to compiled JAR, e.g., E:\\projects\\pulsar-admin-mcp-contrib\\target\\mcp-contrib-1.0.0-SNAPSHOT.jar", "--transport", "stdio"], + "cwd": "Project directory, e.g., E:\\projects\\pulsar-admin-mcp-contrib", + "env": { + "PULSAR_SERVICE_URL": "pulsar://localhost:6650", + "PULSAR_ADMIN_URL": "http://localhost:8080" + } + } + } +} +``` + +#### macOS Configuration + +**Config File Location**: `~/Library/Application Support/Claude/claude_desktop_config.json` + +**Configuration Steps**: +1. Open Claude Desktop config file +2. Add the following configuration to the `mcpServers` section: + +```json +{ + "mcpServers": { + "pulsar-admin": { + "command": "java", + "args": ["-jar", "Path to compiled JAR, e.g., /Users/username/projects/pulsar-admin-mcp-contrib/target/mcp-contrib-1.0.0-SNAPSHOT.jar", "--transport", "stdio"], + "cwd": "Project directory, e.g., /Users/username/projects/pulsar-admin-mcp-contrib", + "env": { + "PULSAR_SERVICE_URL": "pulsar://localhost:6650", + "PULSAR_ADMIN_URL": "http://localhost:8080" + } + } + } +} +``` + +**Notes**: +- Replace `Path to compiled JAR` with the actual JAR file path +- Replace `Project directory` with the actual project root directory path +- Ensure Java environment variables are properly configured +- Windows uses backslash `\`, macOS uses forward slash `/` + +### Cherry Studio + +#### STDIO Mode Configuration +Same as above + +#### HTTP Mode Configuration + +**Configuration Steps**: +1. Ensure MCP server is started (HTTP mode) +2. Add HTTP type configuration in Cherry Studio: + +```json +{ + "mcpServers": { + "pulsar-admin-http": { + "type": "http", + "url": "http://localhost:8889/mcp" + } + } +} +``` + +**Configuration Notes**: +- **STDIO Mode**: Suitable for local development, requires full JAR file path +- **HTTP Mode**: Suitable for remote access, requires HTTP server to be started first +- Both modes use the same environment variable configuration for Pulsar cluster connection +- Windows uses backslash `\`, macOS uses forward slash `/` + +## 4. Configuration Options + +### Environment Variables +- `PULSAR_SERVICE_URL` (default `pulsar://localhost:6650`) +- `PULSAR_ADMIN_URL` (default `http://localhost:8080`) + +### Command Line Parameters +- `--transport`: http / stdio +- `--port`: HTTP port (default 8889) + +## 5. Tool Inventory + +Covers **Cluster** / **Tenant** / **Namespace** / **Topic** / **Subscription** / **Message** / **Schema** / **Monitoring** 8 categories, totaling 71 tools: + +### Cluster Management (10 tools) +- `list-clusters` - List all Pulsar clusters and their status +- `get-cluster-info` - Get detailed information about a specific cluster +- `create-cluster` - Create a new Pulsar cluster +- `update-cluster-config` - Update configuration of an existing cluster +- `delete-cluster` - Delete a Pulsar cluster by name +- `get-cluster-stats` - Get statistics for a given cluster +- `list-brokers` - List all active brokers in a cluster +- `get-broker-stats` - Get statistics for a specific broker +- `get-cluster-failure-domain` - Get failure domains for a cluster +- `set-cluster-failure-domain` - Set or update failure domain configuration + +### Tenant Management (6 tools) +- `list-tenants` - List all Pulsar tenants +- `get-tenant-info` - Get information about a specific tenant +- `create-tenant` - Create a new Pulsar tenant +- `update-tenant` - Update tenant configuration +- `delete-tenant` - Delete a specific tenant +- `get-tenant-stats` - Get statistics for a tenant + +### Namespace Management (10 tools) +- `list-namespaces` - List all namespaces +- `get-namespace-info` - Get namespace information +- `create-namespace` - Create a new namespace +- `delete-namespace` - Delete a namespace +- `set-retention-policy` - Set retention policy for a namespace +- `get-retention-policy` - Get retention policy for a namespace +- `set-backlog-quota` - Set backlog quota for a namespace +- `get-backlog-quota` - Get backlog quota for a namespace +- `clear-namespace-backlog` - Clear backlog for a namespace +- `get-namespace-stats` - Get namespace statistics + +### Topic Management (15 tools) +- `list-topics` - List all topics +- `create-topic` - Create a new topic +- `delete-topic` - Delete a topic +- `get-topic-stats` - Get topic statistics +- `get-topic-metadata` - Get topic metadata +- `update-topic-partitions` - Update topic partition count +- `compact-topic` - Compact a topic +- `unload-topic` - Unload a topic +- `get-topic-backlog` - Get topic backlog information +- `expire-topic-messages` - Expire messages in a topic +- `peek-messages` - Peek messages from a topic +- `peek-topic-messages` - Peek messages from a topic without consuming +- `reset-topic-cursor` - Reset topic cursor +- `get-topic-internal-stats` - Get internal topic statistics +- `get-partitioned-metadata` - Get partitioned topic metadata + +### Subscription Management (10 tools) +- `list-subscriptions` - List all subscriptions +- `create-subscription` - Create a new subscription +- `delete-subscription` - Delete a subscription +- `get-subscription-stats` - Get subscription statistics +- `reset-subscription-cursor` - Reset subscription cursor +- `skip-messages` - Skip messages in a subscription +- `expire-subscription-messages` - Expire messages in a subscription +- `pause-subscription` - Pause a subscription +- `resume-subscription` - Resume a subscription +- `unsubscribe` - Unsubscribe from a topic + +### Message Operations (8 tools) +- `peek-message` - Peek messages from a subscription without acknowledging +- `examine-messages` - Examine messages from a topic without consuming +- `skip-all-messages` - Skip all messages in a subscription +- `expire-all-messages` - Expire all messages in a subscription +- `get-message-backlog` - Get message backlog count for a subscription +- `send-message` - Send a message to a topic +- `get-message-stats` - Get message statistics for a topic or subscription +- `receive-messages` - Receive messages from a topic + +### Schema Management (6 tools) +- `get-schema-info` - Get schema information for a topic +- `get-schema-version` - Get schema version for a topic +- `get-all-schema-versions` - Get all schema versions for a topic +- `upload-schema` - Upload a new schema to a topic +- `delete-schema` - Delete schema for a topic +- `test-schema-compatibility` - Test schema compatibility + +### Monitoring & Diagnostics (6 tools) +- `monitor-cluster-performance` - Monitor cluster performance metrics +- `monitor-topic-performance` - Monitor topic performance metrics +- `monitor-subscription-performance` - Monitor subscription performance +- `health-check` - Check cluster, topic, and subscription health +- `connection-diagnostics` - Run connection diagnostics with different test depths +- `backlog-analysis` - Analyze message backlog within a namespace + +> **Note**: When only PulsarAdmin is initialized, message send/consume related tools will return `not_implemented`; to enable them, initialize PulsarClient and create producer/consumer. + +## Natural Language Interaction Demo (Use Cases) + +The following examples demonstrate typical workflows of triggering tool calls with natural language in MCP clients. Actual returned fields vary by cluster. + +### 1. Tenant and Namespace Management + +**Prompt:** +> Show me all tenants in the cluster; create namespace ns-orders under tenant1, then show me the statistics for this namespace. + +**Triggered:** +`list-tenants` → `create-namespace(tenant=tenant1, namespace=ns-orders)` → `get-namespace-stats(...)` + +### 2. Create Partitioned Topic and Scale + +**Prompt:** +> Create topic orders under public/default with 8 partitions; then scale to 16 partitions and show me the partition metadata. + +**Triggered:** +`create-topic(partitions=8)` → `update-topic-partitions(16)` → `get-partitioned-metadata` + +### 3. Clear Backlog and Compact + +**Prompt:** +> public/default/orders has a large backlog, clear it; then do a compaction. + +**Triggered:** +`get-topic-backlog` → `expire-topic-messages/clear-namespace-backlog` → `compact-topic` + +### 4. Schema Upload and Compatibility Testing + +**Prompt:** +> Set Avro schema for persistent://public/default/orders: orderId:string, amount:double, and check compatibility. + +**Triggered:** +`upload-schema(type=AVRO, schemaJson=...)` → `test-schema-compatibility` / `get-schema-info` + +### 5. Subscription Management and Cursor Reset + +**Prompt:** +> Create failover subscription sub-a on orders, then reset cursor to 1 hour ago. + +**Triggered:** +`create-subscription(type=failover)` → `reset-subscription-cursor(timestamp=now-1h)` + +## Best Practices + +- **Topic Naming**: Full name format is `persistent://tenant/namespace/topic`. Short names are allowed, server will normalize. + +- **Failure Domain**: Set Failure Domain for Broker/Bookie to improve rack/availability zone level disaster recovery. + +## License + +Apache License 2.0 (see [LICENSE](../../LICENSE) for details). \ No newline at end of file diff --git a/pulsar-admin-mcp-contrib/claude-desktop-config.json b/pulsar-admin-mcp-contrib/claude-desktop-config.json new file mode 100644 index 0000000..7221b33 --- /dev/null +++ b/pulsar-admin-mcp-contrib/claude-desktop-config.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "pulsar-admin": { + "command": "java", + "args": [ + "-jar", + "E:\\projects2\\pulsar-admin-mcp-contrib\\target\\mcp-contrib-1.0.0-SNAPSHOT.jar", + "--transport", + "stdio" + ], + "cwd": "E:\\projects2\\pulsar-admin-mcp-contrib", + "env": { + "PULSAR_SERVICE_URL": "pulsar://localhost:6650", + "PULSAR_ADMIN_URL": "http://localhost:8080" + } + } + } +} \ No newline at end of file diff --git a/pulsar-admin-mcp-contrib/demo1.gif b/pulsar-admin-mcp-contrib/demo1.gif new file mode 100644 index 0000000..064fe0e Binary files /dev/null and b/pulsar-admin-mcp-contrib/demo1.gif differ diff --git a/pulsar-admin-mcp-contrib/demo2.gif b/pulsar-admin-mcp-contrib/demo2.gif new file mode 100644 index 0000000..ee8cffa Binary files /dev/null and b/pulsar-admin-mcp-contrib/demo2.gif differ diff --git a/pulsar-admin-mcp-contrib/pom.xml b/pulsar-admin-mcp-contrib/pom.xml new file mode 100644 index 0000000..d0ff264 --- /dev/null +++ b/pulsar-admin-mcp-contrib/pom.xml @@ -0,0 +1,207 @@ + + + + 4.0.0 + + + org.apache + pulsar-java-contrib + 1.0.0-SNAPSHOT + + + mcp-contrib + Pulsar Admin MCP Contrib + Apache Pulsar Admin MCP Server for AI integration + + + 3.5.3 + 2.17.2 + 17 + 17 + 11.0.8 + 5.0.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.eclipse.jetty + jetty-bom + 11.0.20 + pom + import + + + + + + + org.apache.pulsar + pulsar-client-admin + test + + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + + + org.eclipse.jetty + jetty-security + ${jetty.version} + + + org.eclipse.jetty + jetty-webapp + ${jetty.version} + + + org.eclipse.jetty + jetty-io + ${jetty.version} + + + org.eclipse.jetty + jetty-util + ${jetty.version} + + + org.eclipse.jetty + jetty-http + ${jetty.version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + io.modelcontextprotocol.sdk + mcp + 0.12.0 + + + org.apache.pulsar + pulsar-client + + + org.apache.pulsar + pulsar-client-admin + + + org.apache.commons + commons-lang3 + 3.18.0 + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.testcontainers + pulsar + test + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-logging + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + org.apache.pulsar.admin.mcp.Main + + + + repackage + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + 2024 + + + diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/Main.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/Main.java new file mode 100644 index 0000000..f9775d3 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/Main.java @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp; + +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.apache.pulsar.admin.mcp.transport.TransportLauncher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Main { + private static final Logger logger = LoggerFactory.getLogger(Main.class); + + public static void main(String[] args) { + try { + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + logger.info("Starting Pulsar Admin MCP Server with options: {}", options); + TransportLauncher.start(options); + } catch (Exception e) { + logger.error("Fatal error starting Pulsar Admin MCP Server: {}", e.getMessage(), e); + System.err.println("Fatal error: " + e.getMessage()); + System.exit(-1); + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/PulsarClientManager.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/PulsarClientManager.java new file mode 100644 index 0000000..84e464a --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/PulsarClientManager.java @@ -0,0 +1,135 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.client; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.springframework.stereotype.Component; + +@Component +public class PulsarClientManager implements AutoCloseable { + + private PulsarAdmin pulsarAdmin; + private PulsarClient pulsarClient; + + private final AtomicBoolean adminInitialized = new AtomicBoolean(); + private final AtomicBoolean clientInitialized = new AtomicBoolean(); + + public void initialize() { + getAdmin(); + getClient(); + } + + public synchronized PulsarAdmin getAdmin() { + if (!adminInitialized.get()) { + initializePulsarAdmin(); + } + return pulsarAdmin; + } + + public synchronized PulsarClient getClient() { + if (!clientInitialized.get()) { + initializePulsarClient(); + } + return pulsarClient; + } + + private void initializePulsarAdmin() { + + if (!adminInitialized.compareAndSet(false, true)) { + return; + } + + boolean success = false; + try { + String adminUrl = System.getenv().getOrDefault("PULSAR_ADMIN_URL", "http://localhost:8080"); + + PulsarAdminBuilder adminBuilder = PulsarAdmin.builder() + .serviceHttpUrl(adminUrl) + .connectionTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS); + + pulsarAdmin = adminBuilder.build(); + + pulsarAdmin.clusters().getClusters(); + success = true; + + } catch (Exception e) { + if (pulsarAdmin != null) { + try { + pulsarAdmin.close(); + } catch (Exception ignore) { + + } + pulsarAdmin = null; + } + adminInitialized.set(false); + throw new RuntimeException("Failed to initialize PulsarAdmin", e); + } finally { + if (!success) { + adminInitialized.set(false); + } + } + } + + private void initializePulsarClient() { + if (!clientInitialized.compareAndSet(false, true)) { + return; + } + boolean success = false; + try { + String serviceUrl = System.getenv().getOrDefault("PULSAR_SERVICE_URL", "pulsar://localhost:6650"); + + var clientBuilder = PulsarClient.builder() + .serviceUrl(serviceUrl) + .operationTimeout(30, TimeUnit.SECONDS) + .connectionTimeout(30, TimeUnit.SECONDS) + .keepAliveInterval(30, TimeUnit.SECONDS); + + this.pulsarClient = clientBuilder.build(); + success = true; + + } catch (Exception e) { + if (pulsarClient != null) { + try { + pulsarClient.close(); + } catch (Exception ignore) { + } + pulsarClient = null; + } + clientInitialized.set(false); + throw new RuntimeException("Failed to initialize PulsarClient", e); + } finally { + if (!success) { + clientInitialized.set(false); + } + } + } + + @Override + public void close() throws Exception { + if (pulsarClient != null) { + pulsarClient.close(); + } + if (pulsarAdmin != null) { + pulsarAdmin.close(); + } + adminInitialized.set(false); + clientInitialized.set(false); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/package-info.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/package-info.java new file mode 100644 index 0000000..0adeeac --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/client/package-info.java @@ -0,0 +1,14 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.client; diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptions.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptions.java new file mode 100644 index 0000000..163f4e6 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptions.java @@ -0,0 +1,84 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.config; + +import lombok.Getter; + +@Getter +public class PulsarMCPCliOptions { + + @Getter + public enum TransportType { + + STDIO("stdio", "Standard input/output (Claude Desktop)"), + HTTP("http", "HTTP Streaming Events (Web application)"); + private final String value; + private final String description; + + TransportType(String value, String description) { + this.value = value; + this.description = description; + } + + public static TransportType fromString(String value) { + for (TransportType t : values()) { + if (t.value.equalsIgnoreCase(value)) { + return t; + } + } + throw new IllegalArgumentException( + value + " is not a valid TransportType. Valid Options: stdio,http"); + } + } + + private TransportType transport = TransportType.STDIO; + private int httpPort = 8889; + + public static PulsarMCPCliOptions parseArgs(String[] args) { + PulsarMCPCliOptions opts = new PulsarMCPCliOptions(); + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "-t", "--transport" -> { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for --transport"); + } + opts.transport = TransportType.fromString(args[++i]); + } + case "--port" -> { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for --port"); + } + try { + opts.httpPort = Integer.parseInt(args[++i]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port number for --port"); + } + } + default -> { + throw new IllegalArgumentException("Unknown argument: " + arg); + } + } + } + return opts; + } + + @Override + public String toString() { + return "PulsarMCPCliOptions{transport=" + transport + + ",httpPort=" + httpPort + '}'; + } +} + diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/package-info.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/package-info.java new file mode 100644 index 0000000..ea73ee4 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/config/package-info.java @@ -0,0 +1,14 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.config; diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/package-info.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/package-info.java new file mode 100644 index 0000000..13e1780 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/package-info.java @@ -0,0 +1,14 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp; diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/BasePulsarTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/BasePulsarTools.java new file mode 100644 index 0000000..d09911b --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/BasePulsarTools.java @@ -0,0 +1,203 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class BasePulsarTools { + + protected static final Logger LOGGER = LoggerFactory.getLogger(BasePulsarTools.class); + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + protected final PulsarAdmin pulsarAdmin; + + public BasePulsarTools(PulsarAdmin pulsarAdmin) { + if (pulsarAdmin == null) { + throw new IllegalArgumentException("pulsarAdmin cannot be null"); + } + this.pulsarAdmin = pulsarAdmin; + } + + protected McpSchema.CallToolResult createSuccessResult(String message, Object data){ + StringBuilder result = new StringBuilder(); + result.append(message).append("\n"); + + if (data != null){ + try { + String jsonData = OBJECT_MAPPER.writerWithDefaultPrettyPrinter() + .writeValueAsString(data); + result.append(jsonData) + .append("\n"); + } catch (Exception e) { + result.append("Result").append(data.toString()).append("\n"); + } + } + + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(result.toString())), + false + ); + } + + protected McpSchema.CallToolResult createErrorResult(String message){ + String errorText = "Error: " + message; + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(errorText)), + true + ); + } + + protected McpSchema.CallToolResult createErrorResult(String message, List suggestions){ + StringBuilder result = new StringBuilder(); + result.append(message).append("\n"); + + if (suggestions != null && !suggestions.isEmpty()) { + suggestions.forEach(s -> result.append(s).append("\n")); + } + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(result.toString())), + true + ); + } + + protected String getStringParam(Map map, String key){ + Object value = map.get(key); + return value == null ? "" : value.toString(); + } + + protected String getRequiredStringParam(Map map, String key) throws IllegalArgumentException{ + String value = getStringParam(map, key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("Required parameter '" + key + "' is missing"); + } + return value.trim(); + } + + protected Integer getIntParam(Map map, String key, Integer defaultValue) { + Object value = map.get(key); + if (value == null) { + return defaultValue; + } + + try { + if (value instanceof Number) { + return ((Number) value).intValue(); + } else { + return Integer.parseInt(value.toString()); + } + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected Boolean getBooleanParam(Map map, String key, Boolean defaultValue) { + Object value = map.get(key); + if (value == null) { + return defaultValue; + } + if (value instanceof Boolean) { + return (Boolean) value; + } else { + return Boolean.parseBoolean(value.toString()); + } + } + + protected Long getLongParam(Map arguments, String timestamp, Long defaultValue) { + Object value = arguments.get(timestamp); + if (value == null) { + return defaultValue; + } + + try { + if (value instanceof Number) { + return ((Number) value).longValue(); + } else { + return Long.parseLong(value.toString()); + } + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected static McpSchema.Tool createTool ( + String name, + String description, + String inputSchema) { + return McpSchema.Tool.builder() + .name(name) + .description(description) + .inputSchema(inputSchema) + .build(); + } + + + protected String buildFullTopicName(Map arguments) { + String topicName = getStringParam(arguments, "topic"); + if (topicName != null && !topicName.isBlank()) { + if (topicName.startsWith("persistent://") || topicName.startsWith("non-persistent://")) { + return topicName.trim(); + } + } + + String tenant = (String) arguments.getOrDefault("tenant", "public"); + String namespace = (String) arguments.getOrDefault("namespace", "default"); + Boolean persistent = (Boolean) arguments.getOrDefault("persistent", true); + + String prefix = persistent ? "persistent://" : "non-persistent://"; + return prefix + tenant + "/" + namespace + "/" + topicName; + } + + protected String resolveNamespace(Map arguments) { + String tenant = getStringParam(arguments, "tenant"); + String namespace = getStringParam(arguments, "namespace"); + + if (namespace != null && namespace.contains("/")) { + return namespace; + } + + if (tenant == null) { + tenant = "public"; + } + + if (namespace == null) { + namespace = "default"; + } + + return tenant + "/" + namespace; + } + + protected void addTopicBreakdown(Map result, String fullTopicName) { + if (fullTopicName.startsWith("persistent://")) { + fullTopicName = fullTopicName.substring("persistent://".length()); + } else if (fullTopicName.startsWith("non-persistent://")) { + fullTopicName = fullTopicName.substring("non-persistent://".length()); + } + + String[] parts = fullTopicName.split("/", 3); + if (parts.length != 3) { + return; + } + + result.put("tenant", parts[0]); + result.put("namespace", parts[1]); + result.put("topicName", parts[2]); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/ClusterTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/ClusterTools.java new file mode 100644 index 0000000..62c6a15 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/ClusterTools.java @@ -0,0 +1,923 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.common.policies.data.ClusterData; +import org.apache.pulsar.common.policies.data.FailureDomain; + +public class ClusterTools extends BasePulsarTools { + + public ClusterTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer){ + registerListClusters(mcpServer); + registerGetClusterInfo(mcpServer); + registerCreateCluster(mcpServer); + registerDeleteCluster(mcpServer); + registerUpdateClusterConfig(mcpServer); + registerGetClusterStats(mcpServer); + registerListBrokers(mcpServer); + registerGetBrokerStats(mcpServer); + registerGetClusterFailureDomain(mcpServer); + registerSetClusterFailureDomain(mcpServer); + } + + private void registerListClusters(McpSyncServer mcpServer){ + McpSchema.Tool tool = createTool( + "list-clusters", + "List all Pulsar clusters and their status", + """ + { + "type": "object", + "properties": {}, + "required": [] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + var clusters = pulsarAdmin.clusters().getClusters(); + + Map clusterDetails = new HashMap<>(); + for (String clusterName :clusters) { + try { + ClusterData clusterData = pulsarAdmin.clusters().getCluster(clusterName); + Map details = new HashMap<>(); + details.put("serviceUrl", clusterData.getServiceUrl()); + details.put("serviceUrlTls", clusterData.getServiceUrlTls()); + details.put("brokerServiceUrl", clusterData.getBrokerServiceUrl()); + details.put("brokerServiceUrlTls", clusterData.getBrokerServiceUrlTls()); + details.put("status", "active"); + clusterDetails.put(clusterName, details); + } catch (Exception e) { + Map details = new HashMap<>(); + details.put("status", "error"); + details.put("error", e.getMessage()); + clusterDetails.put(clusterName, details); + } + } + + Map result = new HashMap<>(); + result.put("clusters", clusters); + result.put("count", clusters.size()); + result.put("clusterDetails", clusterDetails); + + return createSuccessResult("Cluster details", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to list clusters", e); + return createErrorResult("Failed to list clusters" + e.getMessage()); + } + }).build()); + } + + private void registerGetClusterInfo(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-cluster-info", + "Get details information about a specific cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "Name of the cluster to get info about" + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName"); + + ClusterData clusterData = pulsarAdmin.clusters().getCluster(clusterName); + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("serviceUrl", clusterData.getServiceUrl()); + result.put("serviceUrlTls", clusterData.getServiceUrlTls()); + result.put("brokerServiceUrl", clusterData.getBrokerServiceUrl()); + result.put("brokerServiceUrlTls", clusterData.getBrokerServiceUrlTls()); + result.put("peerClusterNames", clusterData.getPeerClusterNames()); + result.put("proxyProtocol", clusterData.getProxyProtocol()); + result.put("authenticationPlugin", clusterData.getAuthenticationPlugin()); + result.put("authenticationParameters", clusterData.getAuthenticationParameters()); + result.put("proxyServiceUrl", clusterData.getProxyServiceUrl()); + + return createSuccessResult("Cluster info retrieved", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get cluster info", e); + return createErrorResult("Failed to get cluster info: " + e.getMessage()); + } + }) + .build()); + } + + private void registerCreateCluster(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "create-cluster", + "Create a new Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "Name of the cluster to create" + }, + "serviceUrl": { + "type": "string", + "description": "Service URL for the cluster" + }, + "serviceUrlTls": { + "type": "string", + "description": "TLS service URL for the cluster (optional)" + }, + "brokerServiceUrl": { + "type": "string", + "description": "Broker service URL for the cluster (optional)" + }, + "brokerServiceUrlTls": { + "type": "string", + "description": "TLS broker service URL for the cluster (optional)" + }, + "proxyServiceUrl": { + "type": "string", + "description": "Proxy service URL (optional)" + }, + "authenticationPlugin": { + "type": "string", + "description": "Authentication plugin class name (optional)" + }, + "authenticationParameters": { + "type": "string", + "description": "Authentication parameters (optional)" + } + }, + "required": ["clusterName", "serviceUrl"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName"); + String serviceUrl = getRequiredStringParam(request.arguments(), "serviceUrl"); + String serviceUrlTls = getStringParam(request.arguments(), "serviceUrlTls"); + String brokerServiceUrl = getStringParam(request.arguments(), "brokerServiceUrl"); + String brokerServiceUrlTls = getStringParam(request.arguments(), "brokerServiceUrlTls"); + String proxyServiceUrl = getStringParam(request.arguments(), "proxyServiceUrl"); + String authenticationPlugin = getStringParam(request.arguments(), "authenticationPlugin"); + String authenticationParameters = getStringParam( + request.arguments(), + "authenticationParameters" + ); + + try { + pulsarAdmin.clusters().getCluster(clusterName); + return createErrorResult("Cluster already exists: " + clusterName, + List.of("Choose a different cluster name")); + } catch (PulsarAdminException.NotFoundException ignore) { + + } catch (PulsarAdminException e) { + return createErrorResult("Failed to verify cluster existence: " + e.getMessage()); + } + + var clusterDataBuilder = ClusterData.builder() + .serviceUrl(serviceUrl); + + if (serviceUrlTls != null) { + clusterDataBuilder.serviceUrlTls(serviceUrlTls); + } + if (brokerServiceUrl != null) { + clusterDataBuilder.brokerServiceUrl(brokerServiceUrl); + } + if (brokerServiceUrlTls != null) { + clusterDataBuilder.brokerServiceUrlTls(brokerServiceUrlTls); + } + if (proxyServiceUrl != null) { + clusterDataBuilder.proxyServiceUrl(proxyServiceUrl); + } + if (authenticationPlugin != null) { + clusterDataBuilder.authenticationPlugin(authenticationPlugin); + } + if (authenticationParameters != null) { + clusterDataBuilder.authenticationParameters(authenticationParameters); + } + + pulsarAdmin.clusters().createCluster(clusterName, clusterDataBuilder.build()); + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("serviceUrl", serviceUrl); + result.put("created", true); + + return createSuccessResult("Cluster created successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to create cluster", e); + return createErrorResult("Failed to create cluster: " + e.getMessage()); + } + }) + .build()); + } + + private void registerUpdateClusterConfig(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "update-cluster-config", + "Update configuration of an existing Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "Name of the cluster to update" + }, + "serviceUrl": { + "type": "string", + "description": "Service URL for the cluster (optional)" + }, + "serviceUrlTls": { + "type": "string", + "description": "TLS service URL for the cluster (optional)" + }, + "brokerServiceUrl": { + "type": "string", + "description": "Broker service URL for the cluster (optional)" + }, + "brokerServiceUrlTls": { + "type": "string", + "description": "TLS broker service URL for the cluster (optional)" + }, + "proxyServiceUrl": { + "type": "string", + "description": "Proxy service URL (optional)" + }, + "authenticationPlugin": { + "type": "string", + "description": "Authentication plugin class name (optional)" + }, + "authenticationParameters": { + "type": "string", + "description": "Authentication parameters (optional)" + } + }, + "required": ["clusterName", "serviceUrl"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName"); + String serviceUrl = getStringParam(request.arguments(), "serviceUrl"); + String serviceUrlTls = getStringParam(request.arguments(), "serviceUrlTls"); + String brokerServiceUrl = getStringParam(request.arguments(), "brokerServiceUrl"); + String brokerServiceUrlTls = getStringParam(request.arguments(), "brokerServiceUrlTls"); + String proxyServiceUrl = getStringParam(request.arguments(), "proxyServiceUrl"); + String authenticationPlugin = getStringParam(request.arguments(), "authenticationPlugin"); + String authenticationParameters = getStringParam( + request.arguments(), + "authenticationParameters"); + + ClusterData current; + try { + current = pulsarAdmin.clusters().getCluster(clusterName); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Cluster not found: " + clusterName); + } + + String finalServiceUrl = (serviceUrl != null && !serviceUrl.isBlank()) + ? serviceUrl.trim() + : current.getServiceUrl(); + + var b = ClusterData.builder() + .serviceUrl(finalServiceUrl) + .serviceUrlTls((serviceUrlTls != null + && !serviceUrlTls.isBlank()) + ? serviceUrlTls + : current.getServiceUrlTls()) + .brokerServiceUrl((brokerServiceUrl != null + && !brokerServiceUrl.isBlank()) + ? brokerServiceUrl + : current.getBrokerServiceUrl()) + .brokerServiceUrlTls((brokerServiceUrlTls != null + && !brokerServiceUrlTls.isBlank()) + ? brokerServiceUrlTls + : current.getBrokerServiceUrlTls()) + .proxyServiceUrl((proxyServiceUrl != null + && !proxyServiceUrl.isBlank()) + ? proxyServiceUrl + : current.getProxyServiceUrl()); + + if (authenticationPlugin != null && !authenticationPlugin.isBlank()) { + b.authenticationPlugin(authenticationPlugin); + } else if (current.getAuthenticationPlugin() != null) { + b.authenticationPlugin(current.getAuthenticationPlugin()); + } + if (authenticationParameters != null && !authenticationParameters.isBlank()) { + b.authenticationParameters(authenticationParameters); + } else if (current.getAuthenticationParameters() != null) { + b.authenticationParameters(current.getAuthenticationParameters()); + } + + pulsarAdmin.clusters().updateCluster(clusterName, b.build()); + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("serviceUrl", serviceUrl); + result.put("updated", true); + + return createSuccessResult("Cluster configuration updated successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid input: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to update cluster config", e); + return createErrorResult("Failed to update cluster config: " + e.getMessage()); + } + }) + .build()); + } + + private void registerDeleteCluster(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-cluster", + "Delete a Pulsar cluster by name", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "Name of the cluster to delete" + }, + "force": { + "type": "boolean", + "description": "Force cluster to delete", + "default": false + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName").trim(); + boolean force = getBooleanParam(request.arguments(), "force", false); + + if (!force) { + List tenants = pulsarAdmin.tenants().getTenants(); + List referencingTenants = new ArrayList<>(); + for (String tenant : tenants) { + var info = pulsarAdmin.tenants().getTenantInfo(tenant); + var allowed = info != null ? info.getAllowedClusters() : null; + if (allowed != null && allowed.contains(clusterName)) { + referencingTenants.add(tenant); + } + } + + List referencingNamespaces = new ArrayList<>(); + for (String tenant : tenants) { + var nss = pulsarAdmin.namespaces().getNamespaces(tenant); + for (String ns : nss) { + var repl = pulsarAdmin.namespaces().getNamespaceReplicationClusters(ns); + if (repl != null && repl.contains(clusterName)) { + referencingNamespaces.add(ns); + } + } + } + + if (!referencingTenants.isEmpty() || !referencingNamespaces.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("Cluster '").append(clusterName) + .append("' is still referenced. Use 'force: true' to delete anyway."); + if (!referencingTenants.isEmpty()) { + sb.append(" Referenced by tenants: ").append(referencingTenants); + } + if (!referencingNamespaces.isEmpty()) { + sb.append(" Referenced by namespaces: ").append(referencingNamespaces); + } + return createErrorResult(sb.toString()); + } + } + + pulsarAdmin.clusters().deleteCluster(clusterName); + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("deleted", true); + result.put("forced", force); + + return createSuccessResult("Cluster deleted successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to delete cluster", e); + return createErrorResult("Failed to delete cluster: " + e.getMessage()); + } + }) + .build()); + } + + private void registerGetClusterStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-cluster-stats", + "Get statistics for a given Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "The name of the cluster to retrieve stats for" + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName"); + if (clusterName == null || clusterName.isBlank()) { + return createErrorResult("Missing required parameter: clusterName"); + } + + var brokers = pulsarAdmin.brokers().getActiveBrokers(clusterName); + + Map stats = new HashMap<>(); + stats.put("clusterName", clusterName); + stats.put("activeBrokers", brokers); + stats.put("brokerCount", brokers.size()); + + return createSuccessResult("Cluster stats retrieved successfully", stats); + + } catch (IllegalArgumentException e){ + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get cluster stats", e); + return createErrorResult("Failed to get cluster stats: " + e.getMessage()); + } + }) + .build()); + } + + private void registerListBrokers(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-brokers", + "List all active brokers in a given Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "The name of the Pulsar cluster" + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName").trim(); + if (clusterName.isEmpty()) { + return createErrorResult("clusterName cannot be blank"); + } + + try { + pulsarAdmin.clusters().getCluster(clusterName); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Cluster '" + clusterName + "' not found"); + } + + List active = new ArrayList<>(pulsarAdmin.brokers().getActiveBrokers(clusterName)); + active.sort(String::compareTo); + + String leader = null; + try { + leader = String.valueOf(pulsarAdmin.brokers().getLeaderBroker()); + } catch (Exception ignore) {} + + var dynamicConfigNames = pulsarAdmin.brokers().getDynamicConfigurationNames(); + List dynamicNamesSorted = dynamicConfigNames == null + ? List.of() + : dynamicConfigNames.stream().sorted().toList(); + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("activeBrokers", active); + result.put("brokerCount", active.size()); + result.put("leaderBroker", leader); + result.put("dynamicConfigNames", dynamicNamesSorted); + result.put("available", !active.isEmpty()); + result.put("timestamp", System.currentTimeMillis()); + + String msg = "List of active brokers retrieved successfully" + + (leader != null ? "" : " (leader not available)"); + return createSuccessResult(msg, result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to list brokers", e); + return createErrorResult("Failed to list brokers: " + e.getMessage()); + } + }) + .build()); + } + + private void registerGetBrokerStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-broker-stats", + "Get statistics for a specific Pulsar broker", + """ + { + "type": "object", + "properties": { + "brokerUrl": { + "type": "string", + "description": "The URL of the broker (e.g., 'localhost:8080')" + } + }, + "required": ["brokerUrl"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String brokerUrl = getRequiredStringParam(request.arguments(), "brokerUrl"); + + if (brokerUrl == null || brokerUrl.isBlank()) { + return createErrorResult("Missing required parameter: brokerUrl"); + } + + var brokerStats = pulsarAdmin.brokerStats().getTopics(); + + Map result = new HashMap<>(); + result.put("brokerUrl", brokerUrl); + result.put("stats", brokerStats); + + return createSuccessResult("Broker stats retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get broker stats", e); + return createErrorResult("Failed to get broker stats: " + e.getMessage()); + } + }) + .build() + ); + } + + private void registerGetClusterFailureDomain(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-cluster-failure-domain", + "Get failure domain(s) for a specific Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "The name of the Pulsar cluster" + }, + "domainName": { + "type": "string", + "description": "Optional. If set, only this failure domain will be returned" + }, + "includeEmpty": { + "type": "boolean", + "description": "Include domains with empty broker list", + "default": true + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName").trim(); + String domainName = getStringParam(request.arguments(), "domainName"); + boolean includeEmpty = getBooleanParam(request.arguments(), "includeEmpty", true); + + if (clusterName.isEmpty()) { + return createErrorResult("clusterName cannot be blank"); + } + if (domainName != null) { + domainName = domainName.trim(); + } + + try { + pulsarAdmin.clusters().getCluster(clusterName); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Cluster '" + clusterName + "' not found"); + } + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + + if (domainName != null && !domainName.isEmpty()) { + try { + FailureDomain fd = + pulsarAdmin.clusters().getFailureDomain(clusterName, domainName); + + Set brokers = (fd != null && fd.getBrokers() != null) + ? new HashSet<>(fd.getBrokers()) + : new HashSet<>(); + + if (!includeEmpty && brokers.isEmpty()) { + result.put("domains", List.of()); + result.put("domainCount", 0); + result.put("available", false); + return createSuccessResult( + "Domain exists but filtered by includeEmpty=false", result); + } + + Map item = new HashMap<>(); + item.put("domainName", domainName); + item.put("brokers", brokers.stream().sorted().toList()); + item.put("brokerCount", brokers.size()); + + result.put("domains", List.of(item)); + result.put("domainCount", 1); + result.put("available", true); + + return createSuccessResult( + "Cluster failure domain retrieved successfully", result); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult( + "Domain '" + + domainName + + "' not found in cluster '" + + clusterName + "'"); + } + } else { + Map raw = + pulsarAdmin.clusters().getFailureDomains(clusterName); + if (raw == null) { + raw = Map.of(); + } + + List> domains = new ArrayList<>(); + int brokerTotal = 0; + List emptyDomains = new ArrayList<>(); + + for (Map.Entry e : raw.entrySet()) { + String dn = e.getKey(); + Set brokers = (e.getValue() != null && e.getValue().getBrokers() != null) + ? new HashSet<>(e.getValue().getBrokers()) + : new HashSet<>(); + + if (!includeEmpty && brokers.isEmpty()) { + emptyDomains.add(dn); + continue; + } + brokerTotal += brokers.size(); + + Map item = new HashMap<>(); + item.put("domainName", dn); + item.put("brokers", brokers.stream().sorted().toList()); + item.put("brokerCount", brokers.size()); + domains.add(item); + } + + domains.sort(Comparator.comparing(m -> (String) m.get("domainName"))); + + result.put("domains", domains); + result.put("domainCount", domains.size()); + result.put("brokerTotal", brokerTotal); + if (!emptyDomains.isEmpty()) { + result.put("filteredEmptyDomains", emptyDomains.stream().sorted().toList()); + } + result.put("available", !domains.isEmpty()); + + String msg = "Cluster failure domains retrieved successfully" + + (includeEmpty ? "" : " (empty domains filtered)"); + return createSuccessResult(msg, result); + } + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (PulsarAdminException e) { + return createErrorResult("Pulsar admin error: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get failure domains", e); + return createErrorResult("Failed to get failure domains: " + e.getMessage()); + } + }) + .build()); + } + + private void registerSetClusterFailureDomain(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "set-cluster-failure-domain", + "Set or update a failure domain in a Pulsar cluster", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "The name of the Pulsar cluster" + }, + "domainName": { + "type": "string", + "description": "The name of the failure domain" + }, + "brokers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of broker names in this domain (e.g., ['broker-1', 'broker-2'])", + "minItems": 1 + }, + "minDomains": { + "type": "integer", + "description": "Minimum number of failure domains to tolerate (optional)", + "default": 1, + "minimum": 1 + }, + "validateBrokers": { + "type": "boolean", + "description": "Validate brokers exist & not used in other domains (optional)", + "default": true + } + }, + "required": ["clusterName", "domainName", "brokers"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getRequiredStringParam(request.arguments(), "clusterName").trim(); + String domainName = getRequiredStringParam(request.arguments(), "domainName").trim(); + Integer minDomains = getIntParam(request.arguments(), "minDomains", 1); + boolean validate = getBooleanParam(request.arguments(), + "validateBrokers", true); + + Object brokersObj = request.arguments().get("brokers"); + if (!(brokersObj instanceof List rawList)) { + return createErrorResult("Parameter 'brokers' must be a non-empty string list"); + } + List brokers = new ArrayList<>(rawList.size()); + for (int i = 0; i < rawList.size(); i++) { + Object v = rawList.get(i); + if (!(v instanceof String s) || (s = s.trim()).isEmpty()) { + return createErrorResult("All brokers must be non-empty strings " + + "(invalid at index " + i + ")"); + } + brokers.add(s); + } + if (brokers.isEmpty()) { + return createErrorResult("brokers list cannot be empty"); + } + if (minDomains < 1) { + return createErrorResult("minDomains must be at least 1"); + } + + try { + pulsarAdmin.clusters().getCluster(clusterName); + Set brokerSet = new HashSet<>(brokers); + Map existing = + pulsarAdmin.clusters().getFailureDomains(clusterName); + + if (validate) { + for (Map.Entry e : existing.entrySet()) { + String dn = e.getKey(); + if (dn.equals(domainName)) { + continue; + } + Set used = e.getValue().getBrokers(); + for (String b : brokerSet) { + if (used != null && used.contains(b)) { + return createErrorResult("broker '" + + b + "' already belongs to domain '" + + dn + "'"); + } + } + } + } + + boolean isUpdate = existing.containsKey(domainName); + + FailureDomain domainObj; + domainObj = FailureDomain + .builder().brokers(brokerSet).build(); + + if (isUpdate) { + pulsarAdmin.clusters().updateFailureDomain(clusterName, domainName, domainObj); + } else { + pulsarAdmin.clusters().createFailureDomain(clusterName, domainName, domainObj); + } + + FailureDomain resultDomain = + pulsarAdmin.clusters().getFailureDomain(clusterName, domainName); + + boolean minMet = false; + try { + Map after = + pulsarAdmin.clusters().getFailureDomains(clusterName); + minMet = after != null && after.size() >= minDomains; + } catch (Exception ignore) {} + + Map result = new HashMap<>(); + result.put("clusterName", clusterName); + result.put("domainName", domainName); + result.put("brokers", new ArrayList<>(brokers)); + result.put("actualBrokers", new ArrayList<>(resultDomain.getBrokers())); + result.put("operation", isUpdate ? "update" : "create"); + result.put("set", true); + result.put("timestamp", System.currentTimeMillis()); + result.put("minDomains", minDomains); + result.put("minDomainsMet", minMet); + + String msg = "Failure domain " + (isUpdate ? "updated" : "created") + " successfully" + + (minMet ? "" : " (warning: minDomains not met)"); + return createSuccessResult(msg, result); + + } catch (PulsarAdminException e) { + return createErrorResult("Pulsar admin error: " + e.getMessage()); + } + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to process set failure domain request", e); + return createErrorResult("Failed to process request: " + e.getMessage()); + } + }) + .build()); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MessageTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MessageTools.java new file mode 100644 index 0000000..fd63a28 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MessageTools.java @@ -0,0 +1,813 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.client.api.TypedMessageBuilder; +import org.apache.pulsar.common.policies.data.SubscriptionStats; +import org.apache.pulsar.common.policies.data.TopicStats; + +public class MessageTools extends BasePulsarTools { + + private final PulsarClientManager pulsarClientManager; + private final ConcurrentMap> producerCache = new ConcurrentHashMap<>(); + + public MessageTools(PulsarAdmin pulsarAdmin, PulsarClientManager pulsarClientManager) { + super(pulsarAdmin); + this.pulsarClientManager = pulsarClientManager; + } + + protected PulsarClient getClient() throws Exception { + return pulsarClientManager.getClient(); + } + + private Producer getOrCreateProducer(String fullTopic) throws Exception { + Producer existing = producerCache.get(fullTopic); + if (existing != null) { + return existing; + } + PulsarClient client = getClient(); + if (client == null) { + throw new RuntimeException("PulsarClient is not available. " + + "Please check your Pulsar connection configuration."); + } + if (client.isClosed()) { + throw new RuntimeException("PulsarClient is closed. Please restart the MCP server."); + } + + Producer newProducer = client.newProducer() + .topic(fullTopic) + .enableBatching(true) + .batchingMaxPublishDelay(5, TimeUnit.MILLISECONDS) + .blockIfQueueFull(true) + .compressionType(CompressionType.LZ4) + .sendTimeout(30, TimeUnit.SECONDS) + .create(); + + Producer actual = producerCache.putIfAbsent(fullTopic, newProducer); + if (actual != null) { + try { + newProducer.close(); + } catch (Exception ignored) { + + } + return actual; + } + return newProducer; + } + + public void registerTools(McpSyncServer mcpServer) { + registerPeekMessage(mcpServer); + registerExamineMessages(mcpServer); + registerSkipAllMessages(mcpServer); + registerExpireAllMessages(mcpServer); + registerGetMessageBacklog(mcpServer); + registerSendMessage(mcpServer); + registerGetMessageStats(mcpServer); + registerReceiveMessages(mcpServer); + } + + private void registerPeekMessage(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "peek-message", + "Peek messages from a subscription without acknowledging them", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "The name of the subscription to peek messages from" + }, + "numMessages": { + "type": "integer", + "description": "Number of messages to peek (default: 1)", + "minimum": 1, + "default": 1 + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + int numMessages = getIntParam(request.arguments(), "numMessages", 1); + + if (numMessages <= 0) { + return createErrorResult("Number of messages must be greater than 0."); + } + + List> messages = pulsarAdmin.topics() + .peekMessages(topic, subscriptionName, numMessages); + + List> messageList = new ArrayList<>(); + for (Message msg : messages) { + Map msgInfo = new HashMap<>(); + msgInfo.put("messageId", msg.getMessageId().toString()); + msgInfo.put("properties", msg.getProperties()); + msgInfo.put("publishTime", msg.getPublishTime()); + msgInfo.put("data", new String(msg.getData())); + messageList.add(msgInfo); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("messagesCount", messages.size()); + result.put("messages", messageList); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Peeked " + messageList.size() + " message(s) successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to peek messages", e); + return createErrorResult("Failed to peek messages: " + e.getMessage()); + } + }).build() + ); + } + + private void registerExamineMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "examine-messages", + "Examine messages from a topic without consuming them", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "The name of the subscription to peek messages from" + }, + "numMessages": { + "type": "integer", + "description": "Number of messages to examine", + "minimum": 1, + "default": 5 + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getStringParam(request.arguments(), "subscriptionName"); + int numMessages = getIntParam(request.arguments(), "numMessages", 5); + + if (numMessages <= 0) { + return createErrorResult("Invalid number of messages for examine"); + } + + List> messages = pulsarAdmin.topics().peekMessages( + topic, + subscriptionName, + numMessages); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("messageCount", messages.size()); + + List> detailedMessages = messages.stream() + .map(message -> { + Map messageInfo = new HashMap<>(); + messageInfo.put("messageId", message.getMessageId().toString()); + messageInfo.put("properties", message.getProperties()); + messageInfo.put("eventTime", message.getEventTime()); + messageInfo.put("key", message.getKey()); + messageInfo.put("publishTime", message.getPublishTime()); + messageInfo.put("payloadBase64", + Base64.getEncoder().encodeToString(message.getData())); + try { + messageInfo.put("textUtf8", + new String(message.getData(), StandardCharsets.UTF_8)); + } catch (Exception ignore) { + + } + messageInfo.put("producerName", message.getProducerName()); + return messageInfo; + }) + .collect(Collectors.toList()); + + + result.put("messages", detailedMessages); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Examined messages successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to examine messages", e); + return createErrorResult("Failed to examine messages: " + e.getMessage()); + } + }).build() + ); + } + + private void registerSkipAllMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "skip-all-messages", + "Skip all messages in a subscription (set cursor to latest)", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to skip messages for" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + + pulsarAdmin.topics().skipAllMessages(topic, subscriptionName); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", + subscriptionName); + result.put("skippedAll", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Skipped all messages for subscription: " + + subscriptionName, result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error while skipping messages", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }).build() + ); + } + + private void registerExpireAllMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "expire-all-messages", + "Expire all messages in a subscription immediately", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name whose messages should be expired" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + + try { + var subs = pulsarAdmin.topics().getSubscriptions(topic); + if (subs == null || !subs.contains(subscriptionName)) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + } catch (Exception ignore) { + + } + + pulsarAdmin.topics().expireMessages(topic, subscriptionName, 0); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("expiredAll", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Expired all messages for subscription: " + + subscriptionName, result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error while expiring messages", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }).build() + ); + } + + private void registerGetMessageBacklog(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-message-backlog", + "Get the current backlog message count for a subscription", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to check backlog for" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + + long backlog = 0L; + boolean found = false; + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta != null && meta.partitions > 0) { + var ps = pulsarAdmin.topics().getPartitionedStats(topic, true); + if (ps != null && ps.getPartitions() != null) { + for (var partEntry : ps.getPartitions().entrySet()) { + TopicStats ts = partEntry.getValue(); + if (ts != null && ts.getSubscriptions() != null) { + var sub = ts.getSubscriptions().get(subscriptionName); + if (sub != null) { + backlog += sub.getMsgBacklog(); + found = true; + } + } + } + } + } else { + TopicStats stats = pulsarAdmin.topics().getStats(topic); + if (stats != null && stats.getSubscriptions() != null) { + var sub = stats.getSubscriptions().get(subscriptionName); + if (sub != null) { + backlog = sub.getMsgBacklog(); + found = true; + } + } + } + + if (!found) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("backlog", backlog); + + addTopicBreakdown(result, topic); + return createSuccessResult("Backlog retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error while getting message backlog", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }).build() + ); + } + + private void registerGetMessageStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-message-stats", + "Get message statistics for a topic or subscription", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Optional subscription name to get stats for a specific subscription" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getStringParam(request.arguments(), "subscriptionName"); + + Map result = new HashMap<>(); + result.put("topic", topic); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta != null && meta.partitions > 0) { + var ps = pulsarAdmin.topics().getPartitionedStats(topic, true); + if (ps == null) { + return createErrorResult("Failed to fetch partitioned stats"); + } + + result.put("msgInCounter", ps.getMsgInCounter()); + result.put("msgOutCounter", ps.getMsgOutCounter()); + result.put("bytesInCounter", ps.getBytesInCounter()); + result.put("bytesOutCounter", ps.getBytesOutCounter()); + + if (subscriptionName != null && !subscriptionName.isBlank()) { + long backlog = 0L; + double rateOut = 0D; + double rateRedeliver = 0D; + boolean found = false; + + if (ps.getPartitions() != null) { + for (var partEntry : ps.getPartitions().entrySet()) { + TopicStats ts = partEntry.getValue(); + if (ts != null && ts.getSubscriptions() != null) { + var sub = ts.getSubscriptions().get(subscriptionName); + if (sub != null) { + backlog += sub.getMsgBacklog(); + rateOut += sub.getMsgRateOut(); + rateRedeliver += sub.getMsgRateRedeliver(); + found = true; + } + } + } + } + + if (!found) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + result.put("subscriptionName", subscriptionName); + result.put("msgBacklog", backlog); + result.put("msgRateOut", rateOut); + result.put("msgRateRedeliver", rateRedeliver); + } + } else { + TopicStats stats = pulsarAdmin.topics().getStats(topic); + result.put("msgInCounter", stats.getMsgInCounter()); + result.put("msgOutCounter", stats.getMsgOutCounter()); + result.put("bytesInCounter", stats.getBytesInCounter()); + result.put("bytesOutCounter", stats.getBytesOutCounter()); + + if (subscriptionName != null && !subscriptionName.isBlank()) { + if (stats.getSubscriptions() == null + || !stats.getSubscriptions().containsKey(subscriptionName)) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + SubscriptionStats subStats = stats.getSubscriptions().get(subscriptionName); + result.put("subscriptionName", subscriptionName); + result.put("msgBacklog", subStats.getMsgBacklog()); + result.put("msgRateOut", subStats.getMsgRateOut()); + result.put("msgRateRedeliver", subStats.getMsgRateRedeliver()); + } + } + + addTopicBreakdown(result, topic); + return createSuccessResult("Fetched message stats successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error while getting message stats", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }).build() + ); + } + + private void registerSendMessage(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "send-message", + "Send a message to a specified topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "message": { + "type": "string", + "description": "The message content to send" + }, + "key": { + "type": "string", + "description": "Optional message key" + }, + "properties": { + "type": "object", + "description": "Optional key-value properties for the message" + } + }, + "required": ["topic", "message"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullTopic = buildFullTopicName(request.arguments()); + String message = getRequiredStringParam(request.arguments(), "message"); + String key = getStringParam(request.arguments(), "key"); + + Map propsSafe = new HashMap<>(); + Object propsObj = request.arguments().get("properties"); + if (propsObj instanceof Map raw) { + for (var e : raw.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + propsSafe.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + } + + Producer producer = getOrCreateProducer(fullTopic); + + TypedMessageBuilder builder = producer.newMessage() + .value(message.getBytes(StandardCharsets.UTF_8)); + + if (key != null && !key.isEmpty()) { + builder = builder.key(key); + } + if (!propsSafe.isEmpty()) { + builder = builder.properties(propsSafe); + } + + MessageId msgId = builder.send(); + + return createSuccessResult("Message sent", Map.of( + "topic", fullTopic, + "messageId", msgId.toString(), + "messageContent", message, + "bytes", message.getBytes(StandardCharsets.UTF_8).length + )); + } catch (IllegalArgumentException iae) { + return createErrorResult("Invalid arguments: " + iae.getMessage()); + } catch (Exception e) { + return createErrorResult("Failed to send message: " + e.getMessage()); + } + }).build()); + } + + private void registerReceiveMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "receive-messages", + "Receive messages from a Pulsar topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name for the consumer" + }, + "messageCount": { + "type": "integer", + "description": "Number of messages to receive", + "minimum": 1, + "default": 10 + }, + "timeoutMs": { + "type": "integer", + "description": "Total timeout budget in milliseconds (not per-message)", + "minimum": 1000, + "default": 5000 + }, + "ack": { + "type": "boolean", + "description": "Acknowledge messages after receiving (default: true)", + "default": true + }, + "subscriptionType": { + "type": "string", + "description": "Consumer subscription type", + "enum": ["Exclusive","Shared","Failover","Key_Shared"], + "default": "Shared" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + int messageCount = Math.max(1, getIntParam(request.arguments(), "messageCount", 10)); + + if (messageCount > 1000) { + return createErrorResult("messageCount too large (max 1000)"); + } + int timeoutMs = Math.max(1000, getIntParam(request.arguments(), "timeoutMs", 5000)); + if (timeoutMs > 120_000) { + return createErrorResult("timeoutMs too large (max 120000)"); + } + boolean ack = getBooleanParam(request.arguments(), "ack", true); + String subTypeStr = getStringParam(request.arguments(), "subscriptionType"); + SubscriptionType subType = SubscriptionType.Shared; + if (subTypeStr != null) { + try { + subType = SubscriptionType.valueOf(subTypeStr.replace('-', '_')); + } catch (IllegalArgumentException ignore) { + + } + } + + try { + List subs = pulsarAdmin.topics().getSubscriptions(topic); + if (subs == null || !subs.contains(subscriptionName)) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + } catch (Exception e) { + LOGGER.warn("Failed to verify subscription existence for {}: {}", topic, e.toString()); + } + + List> out = new ArrayList<>(); + + try { + PulsarClient client = getClient(); + if (client == null) { + return createErrorResult("Pulsar Client is not available"); + } + + int rq = Math.min(Math.max(messageCount, 10), 1000); + long deadline = System.nanoTime() + (timeoutMs * 1_000_000L); + + try (Consumer consumer = client.newConsumer() + .topic(topic) + .subscriptionName(subscriptionName) + .subscriptionType(subType) + .receiverQueueSize(rq) + .subscribe()) { + + for (int i = 0; i < messageCount; i++) { + long remainMs = Math.max(0L, (deadline - System.nanoTime()) / 1_000_000L); + if (remainMs == 0) { + break; + } + + Message msg = consumer.receive((int) Math.min(remainMs, Integer.MAX_VALUE), + TimeUnit.MILLISECONDS); + if (msg == null) { + break; + } + + Map m = new HashMap<>(); + m.put("messageId", msg.getMessageId().toString()); + m.put("key", msg.getKey()); + byte[] data = msg.getData(); + m.put("dataUtf8", safeToUtf8(data)); + m.put("dataBase64", java.util.Base64.getEncoder().encodeToString(data)); + m.put("publishTime", msg.getPublishTime()); + m.put("eventTime", msg.getEventTime()); + m.put("properties", msg.getProperties()); + m.put("producerName", msg.getProducerName()); + try { + m.put("redeliveryCount", msg.getRedeliveryCount()); + } catch (Throwable ignore) {} + m.put("dataSize", data != null ? data.length : 0); + + out.add(m); + + if (ack) { + try { + consumer.acknowledge(msg); + } catch (Exception ackEx) { + LOGGER.warn("Acknowledge failed: {}", ackEx.toString()); + } + } + } + } + } catch (Exception clientException) { + LOGGER.error("Failed to receive messages using PulsarClient", clientException); + return createErrorResult("Failed to receive messages - PulsarClient error: " + + clientException.getMessage()); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("requestCount", messageCount); + result.put("receivedCount", out.size()); + result.put("ack", ack); + result.put("subscriptionType", subType.toString()); + result.put("timeoutMs", timeoutMs); + result.put("messages", out); + + addTopicBreakdown(result, topic); + return createSuccessResult("Messages received successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid input parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error while receiving messages", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }) + .build()); + } + + private static String safeToUtf8(byte[] data) { + if (data == null) { + return null; + } + try { + return new String(data, StandardCharsets.UTF_8); + } catch (Throwable ignore) { + return null; + } + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MonitoringTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MonitoringTools.java new file mode 100644 index 0000000..4477360 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/MonitoringTools.java @@ -0,0 +1,801 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.policies.data.PublisherStats; +import org.apache.pulsar.common.policies.data.SubscriptionStats; +import org.apache.pulsar.common.policies.data.TopicStats; + +public class MonitoringTools extends BasePulsarTools{ + public MonitoringTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + PulsarClient pulsarClient; + + public void registerTools(McpSyncServer mcpServer) { + registerMonitorClusterPerformance(mcpServer); + registerMonitorSubscriptionPerformance(mcpServer); + registerMonitorTopicPerformance(mcpServer); + registerHealthCheck(mcpServer); + registerConnectionDiagnostics(mcpServer); + registerBacklogAnalysis(mcpServer); + } + + private void registerMonitorClusterPerformance(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "monitor-cluster-performance", + "Monitor specific Pulsar cluster performance metrics and health", + """ + { + "type": "object", + "properties": { + "clusterName": { + "type": "string", + "description": "Cluster name to monitor" + }, + "includeBrokerStats": { + "type": "boolean", + "description": "Include detailed broker statistics (default: true)", + "default": true + }, + "includeTopicStats": { + "type": "boolean", + "description": "Include aggregated topic statistics (default: false, can be slow)", + "default": false + }, + "maxTopics": { + "type": "integer", + "description": "Maximum top topics to include when includeTopicStats is true (default: 10)", + "default": 10, + "minimum": 1, + "maximum": 50 + } + }, + "required": ["clusterName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String clusterName = getStringParam(request.arguments(), "clusterName"); + boolean includeBrokerStats = getBooleanParam(request.arguments(), "includeBrokerStats", true); + int maxTopics = getIntParam(request.arguments(), "maxTopics", 10); + if (maxTopics < 1) { + maxTopics = 1; + } + if (maxTopics > 50) { + maxTopics = 50; + } + + Map result = new HashMap<>(); + result.put("timestamp", System.currentTimeMillis()); + + var clusters = pulsarAdmin.clusters().getClusters(); + if (clusterName == null || !clusters.isEmpty()) { + clusterName = clusters.get(0); + } + if (clusterName == null || clusterName.isBlank()) { + return createErrorResult("clusterName is required and no clusters found"); + } + result.put("clusterName", clusterName); + + try { + var brokers = pulsarAdmin.brokers().getActiveBrokers(clusterName); + result.put("activeBrokers", brokers.size()); + result.put("brokerList", brokers); + + if (includeBrokerStats) { + Map brokerStats = new HashMap<>(); + for (String broker : brokers) { + try { + brokerStats.put(broker, Map.of("status", "active")); + } catch (Exception e) { + brokerStats.put(broker, Map.of("status", "error", "error", + e.getMessage())); + } + } + result.put("brokerStats", brokerStats); + } + } catch (Exception e) { + result.put("brokerError", e.getMessage()); + } + + result.put("clusterHealth", result.containsKey("brokerError") ? "error" : "healthy"); + return createSuccessResult("Cluster performance monitoring completed", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + return createErrorResult("Failed to monitor cluster performance:" + e.getMessage()); + } + }) + .build()); + } + + private void registerMonitorTopicPerformance(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "monitor-topic-performance", + "Monitor specific Pulsar topic performance metrics and health", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "includeDetails": { + "type": "boolean", + "description": "Include detailed subscription and consumer statistics (default: false)", + "default": false + }, + "includeInternalStats": { + "type": "boolean", + "description": "Include internal topic statistics like ledger info (default: false)", + "default": false + }, + "maxSubscriptions": { + "type": "integer", + "description": "Maximum number of subscriptions to include in details (default: 10)", + "default": 10, + "minimum": 1, + "maximum": 50 + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + boolean includeDetails = getBooleanParam(request.arguments(), + "includeDetails", false); + boolean includeInternalStats = getBooleanParam(request.arguments(), + "includeInternalStats", false); + int maxSubscriptions = getIntParam(request.arguments(), + "maxSubscriptions", 10); + + Map result = new HashMap<>(); + result.put("timestamp", System.currentTimeMillis()); + result.put("topic", topic); + + try { + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + double msgRateIn = 0.0, msgRateOut = 0.0, msgThroughputIn = 0.0, msgThroughputOut = 0.0; + long storageSize = 0L; + double averageMsgSize = 0.0; + int avgCount = 0; + + Map subMapAgg = new HashMap<>(); + List publishersAgg = new ArrayList<>(); + + if (meta != null && meta.partitions > 0) { + for (int i = 0; i < meta.partitions; i++) { + var s = pulsarAdmin.topics().getStats(topic + "-partition-" + i); + msgRateIn += s.getMsgRateIn(); + msgRateOut += s.getMsgRateOut(); + msgThroughputIn += s.getMsgThroughputIn(); + msgThroughputOut += s.getMsgThroughputOut(); + storageSize += s.getStorageSize(); + if (s.getAverageMsgSize() > 0) { + averageMsgSize += s.getAverageMsgSize(); + avgCount++; + } + + if (s.getSubscriptions() != null) { + s.getSubscriptions().forEach((k, v) -> + subMapAgg.merge(k, v, (a, b) -> { + return b; + })); + } + if (s.getPublishers() != null) { + publishersAgg.addAll(s.getPublishers()); + } + } + } else { + var s = pulsarAdmin.topics().getStats(topic); + msgRateIn = s.getMsgRateIn(); + msgRateOut = s.getMsgRateOut(); + msgThroughputIn = s.getMsgThroughputIn(); + msgThroughputOut = s.getMsgThroughputOut(); + storageSize = s.getStorageSize(); + averageMsgSize = s.getAverageMsgSize(); + avgCount = (averageMsgSize > 0) ? 1 : 0; + if (s.getSubscriptions() != null) { + subMapAgg.putAll(s.getSubscriptions()); + } + if (s.getPublishers() != null) { + publishersAgg.addAll(s.getPublishers()); + } + } + + result.put("msgRateIn", msgRateIn); + result.put("msgRateOut", msgRateOut); + result.put("msgThroughputIn", msgThroughputIn); + result.put("msgThroughputOut", msgThroughputOut); + result.put("storageSize", storageSize); + result.put("averageMsgSize", avgCount == 0 ? 0.0 : (averageMsgSize / avgCount)); + + int publishersCount = publishersAgg == null ? 0 : publishersAgg.size(); + int subscriptionsCount = subMapAgg == null ? 0 : subMapAgg.size(); + result.put("publishersCount", publishersCount); + result.put("subscriptionsCount", subscriptionsCount); + + int totalConsumers = 0; + long totalBacklog = 0L; + if (subMapAgg != null) { + for (var sub : subMapAgg.values()) { + if (sub.getConsumers() != null) { + totalConsumers += sub.getConsumers().size(); + } + totalBacklog += sub.getMsgBacklog(); + } + } + result.put("totalConsumers", totalConsumers); + result.put("totalBacklog", totalBacklog); + + if (includeDetails && subMapAgg != null) { + Map subscriptionStats = new HashMap<>(); + List subscriptionNames = new ArrayList<>(subMapAgg.keySet()); + if (subscriptionNames.size() > maxSubscriptions) { + subscriptionNames = subscriptionNames.subList(0, maxSubscriptions); + } + for (String subName : subscriptionNames) { + var sub = subMapAgg.get(subName); + Map subDetail = new HashMap<>(); + subDetail.put("msgRateOut", sub.getMsgRateOut()); + subDetail.put("msgThroughputOut", sub.getMsgThroughputOut()); + subDetail.put("msgBacklog", sub.getMsgBacklog()); + subDetail.put("msgRateExpired", sub.getMsgRateExpired()); + subDetail.put("consumersCount", sub.getConsumers() == null + ? 0 : sub.getConsumers().size()); + subDetail.put("type", sub.getType()); + + List> consumerDetails = new ArrayList<>(); + if (sub.getConsumers() != null) { + for (var consumer : sub.getConsumers()) { + Map consumerInfo = new HashMap<>(); + consumerInfo.put("consumerName", consumer.getConsumerName()); + consumerInfo.put("msgRateOut", consumer.getMsgRateOut()); + consumerInfo.put("msgThroughputOut", consumer.getMsgThroughputOut()); + consumerInfo.put("availablePermits", consumer.getAvailablePermits()); + consumerInfo.put("unackedMessages", consumer.getUnackedMessages()); + consumerDetails.add(consumerInfo); + } + } + subDetail.put("consumers", consumerDetails); + subscriptionStats.put(subName, subDetail); + } + result.put("subscriptionStats", subscriptionStats); + + List> publisherDetails = new ArrayList<>(); + if (publishersAgg != null) { + for (var publisher : publishersAgg) { + Map pubInfo = new HashMap<>(); + pubInfo.put("producerName", publisher.getProducerName()); + pubInfo.put("msgRateIn", publisher.getMsgRateIn()); + pubInfo.put("msgThroughputIn", publisher.getMsgThroughputIn()); + pubInfo.put("averageMsgSize", publisher.getAverageMsgSize()); + publisherDetails.add(pubInfo); + } + } + result.put("publisherDetails", publisherDetails); + } + + if (includeInternalStats) { + try { + var internalStats = (meta != null && meta.partitions > 0) + ? pulsarAdmin.topics().getInternalStats(topic + "-partition-0") + : pulsarAdmin.topics().getInternalStats(topic); + Map internal = new HashMap<>(); + internal.put("numberOfEntries", internalStats.numberOfEntries); + internal.put("totalSize", internalStats.totalSize); + internal.put("currentLedgerEntries", internalStats.currentLedgerEntries); + internal.put("currentLedgerSize", internalStats.currentLedgerSize); + internal.put("ledgerCount", internalStats.ledgers == null + ? 0 : internalStats.ledgers.size()); + internal.put("cursorCount", internalStats.cursors == null + ? 0 : internalStats.cursors.size()); + result.put("internalStats", internal); + } catch (Exception e) { + result.put("internalStatsError", e.getMessage()); + } + } + + } catch (Exception e) { + result.put("statsError", e.getMessage()); + } + + String topicHealth = "healthy"; + if (result.containsKey("statsError")) { + topicHealth = "error"; + } else { + Long totalBacklogVal = (Long) result.get("totalBacklog"); + Double msgRateInVal = (Double) result.get("msgRateIn"); + Double msgRateOutVal = (Double) result.get("msgRateOut"); + if (totalBacklogVal != null && totalBacklogVal > 100_000) { + topicHealth = "backlog_high"; + } else if (msgRateInVal != null && msgRateOutVal != null + && msgRateInVal > msgRateOutVal * 1.5) { + topicHealth = "consumption_slow"; + } + } + result.put("topicHealth", topicHealth); + + addTopicBreakdown(result, topic); + return createSuccessResult("Topic performance monitoring completed", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + return createErrorResult("Failed to monitor topic performance: " + e.getMessage()); + } + }) + .build()); + } + + private void registerMonitorSubscriptionPerformance(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "monitor-subscription-performance", + "Monitor performance metrics of a subscription on a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "subscriptionName": { + "type": "string", + "description": "Name of the subscription to monitor" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + + long msgBacklog = 0L; + double msgRateOut = 0.0, msgThroughputOut = 0.0; + int consumersCount = 0; + boolean blockedOnUnacked = false; + Object subscriptionType = null; + + if (meta != null && meta.partitions > 0) { + for (int i = 0; i < meta.partitions; i++) { + var stats = pulsarAdmin.topics().getStats(topic + "-partition-" + i); + var sub = (stats.getSubscriptions() == null) + ? null : stats.getSubscriptions().get(subscriptionName); + if (sub == null) { + continue; + } + + msgBacklog += sub.getMsgBacklog(); + msgRateOut += sub.getMsgRateOut(); + msgThroughputOut += sub.getMsgThroughputOut(); + consumersCount += (sub.getConsumers() == null ? 0 : sub.getConsumers().size()); + blockedOnUnacked = blockedOnUnacked || sub.isBlockedSubscriptionOnUnackedMsgs(); + if (subscriptionType == null) { + subscriptionType = sub.getType(); + } + } + if (msgBacklog == 0 && consumersCount == 0 && subscriptionType == null) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + } else { + TopicStats stats = pulsarAdmin.topics().getStats(topic); + var subscriptions = stats.getSubscriptions(); + if (subscriptions == null || !subscriptions.containsKey(subscriptionName)) { + return createErrorResult("Subscription not found: " + subscriptionName); + } + var sub = subscriptions.get(subscriptionName); + msgBacklog = sub.getMsgBacklog(); + msgRateOut = sub.getMsgRateOut(); + msgThroughputOut = sub.getMsgThroughputOut(); + consumersCount = (sub.getConsumers() == null ? 0 : sub.getConsumers().size()); + blockedOnUnacked = sub.isBlockedSubscriptionOnUnackedMsgs(); + subscriptionType = sub.getType(); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("timestamp", System.currentTimeMillis()); + result.put("msgBacklog", msgBacklog); + result.put("msgRateOut", msgRateOut); + result.put("msgThroughputOut", msgThroughputOut); + result.put("consumersCount", consumersCount); + result.put("blockedSubscriptionOnUnackedMsgs", blockedOnUnacked); + result.put("subscriptionType", subscriptionType); + + addTopicBreakdown(result, topic); + return createSuccessResult("Subscription performance retrieved", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to monitor subscription performance", e); + return createErrorResult("Failed to monitor subscription performance: " + e.getMessage()); + } + }).build()); + } + + private void registerBacklogAnalysis(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "backlog-analysis", + "Analyze message backlog within a Pulsar namespace " + + "and report topics/subscriptions exceeding a given threshold", + """ + { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "The namespace to analyze (e.g., 'public/default')" + }, + "threshold": { + "type": "integer", + "description": "Message backlog threshold for alerts (default: 1000)", + "default": 1000, + "minimum": 0 + }, + "includeDetails": { + "type": "boolean", + "description": "Include detailed per-topic and per-subscription stats (default: false)", + "default": false + } + }, + "required": ["namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String namespace = getStringParam(request.arguments(), "namespace"); + int threshold = getIntParam(request.arguments(), "threshold", 1000); + boolean includeDetails = getBooleanParam(request.arguments(), "includeDetails", false); + + Map result = new HashMap<>(); + result.put("timestamp", System.currentTimeMillis()); + result.put("namespace", namespace); + result.put("threshold", threshold); + + var topics = pulsarAdmin.namespaces().getTopics(namespace); + List alertTopics = new ArrayList<>(); + Map details = new HashMap<>(); + + for (String topic : topics) { + try { + var stats = pulsarAdmin.topics().getStats(topic); + long totalBacklog = stats.getSubscriptions().values() + .stream() + .mapToLong(sub -> sub.getMsgBacklog()) + .sum(); + + if (totalBacklog > threshold) { + alertTopics.add(topic); + } + + if (includeDetails) { + Map subsDetail = new HashMap<>(); + stats.getSubscriptions().forEach((subName, subStats) -> { + subsDetail.put(subName, Map.of( + "backlogMessages", subStats.getMsgBacklog(), + "isHealthy", subStats.getMsgBacklog() <= threshold + )); + }); + details.put(topic, Map.of( + "totalBacklog", totalBacklog, + "subscriptions", subsDetail + )); + } + } catch (Exception e) { + details.put(topic, Map.of( + "error", e.getMessage() + )); + } + } + + result.put("alertTopics", alertTopics); + if (includeDetails) { + result.put("details", details); + } + + return createSuccessResult("Backlog analysis completed", result); + + } catch (Exception e) { + return createErrorResult("Failed to perform backlog analysis: " + e.getMessage()); + } + }) + .build()); + } + + private void registerConnectionDiagnostics(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "connection-diagnostics", + "Run connection diagnostics to Pulsar cluster with different test depths", + """ + { + "type": "object", + "properties": { + "testType": { + "type": "string", + "description": "Type of diagnostics: basic, detailed, network", + "enum": ["basic", "detailed", "network"] + }, + "testTopic": { + "type": "string", + "description": "Topic for testing (default: persistent://public/default/connection-test)" + } + }, + "required": ["testType"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + Map result = new LinkedHashMap<>(); + String testType = getRequiredStringParam(request.arguments(), "testType").toLowerCase(); + String testTopic = getStringParam(request.arguments(), "testTopic"); + if (testTopic == null || testTopic.isEmpty()) { + testTopic = "persistent://public/default/connection-test"; + } + + try { + try { + String leaderBroker = String.valueOf(pulsarAdmin.brokers().getLeaderBroker()); + result.put("adminApiReachable", true); + result.put("leaderBroker", leaderBroker); + } catch (Exception e) { + result.put("adminApiReachable", false); + result.put("adminApiError", e.getMessage()); + return createErrorResult("Admin API unreachable"); + } + + if ("basic".equals(testType)) { + result.put("diagnosticsLevel", "basic"); + return createSuccessResult("Basic connection check completed", result); + } + String subName = "connection-diagnostics-sub-" + System.currentTimeMillis(); + String sentPayload = "connection-test-" + System.currentTimeMillis(); + + long sendStartNs; + long sendEndNs; + long receiveEndNs = 0L; + + try (Consumer consumer = pulsarClient.newConsumer() + .topic(testTopic) + .subscriptionName(subName) + .subscriptionType(SubscriptionType.Exclusive) + .subscribe(); + Producer producer = pulsarClient.newProducer() + .topic(testTopic) + .enableBatching(false) + .sendTimeout(5, TimeUnit.SECONDS) + .create()) { + + sendStartNs = System.nanoTime(); + MessageId msgId = producer.newMessage() + .value(sentPayload.getBytes(StandardCharsets.UTF_8)) + .send(); + sendEndNs = System.nanoTime(); + result.put("clientProducerReachable", true); + result.put("testMessageId", msgId.toString()); + + Message msg = consumer.receive(5, TimeUnit.SECONDS); + if (msg != null) { + String received = new String(msg.getData(), StandardCharsets.UTF_8); + if (sentPayload.equals(received)) { + result.put("clientConsumerReachable", true); + result.put("receivedTestMessage", received); + } else { + result.put("clientConsumerReachable", false); + result.put("consumerError", "Received unexpected payload"); + } + consumer.acknowledge(msg); + receiveEndNs = System.nanoTime(); + } else { + result.put("clientConsumerReachable", false); + result.put("consumerError", "No message received in time"); + } + } catch (Exception e) { + result.put("clientProducerReachable", false); + result.put("clientProducerError", e.getMessage()); + return createErrorResult("Producer/Consumer test failed"); + } + + result.put("producerTimeMs", (sendEndNs - sendStartNs) / 1_000_000.0); + + if ("detailed".equals(testType)) { + result.put("diagnosticsLevel", "detailed"); + return createSuccessResult("Detailed connection check completed", result); + } + + if ("network".equals(testType) && Boolean.TRUE.equals(result.get("clientConsumerReachable"))) { + double roundTripMs = (receiveEndNs - sendStartNs) / 1_000_000.0; + result.put("roundTripLatencyMs", roundTripMs); + + int testSize = 1024 * 100; + byte[] payload = new byte[testSize]; + Arrays.fill(payload, (byte) 65); + long totalBytes = 0; + long bwStart = System.nanoTime(); + + try (Producer producer = pulsarClient.newProducer() + .topic(testTopic) + .enableBatching(false) + .sendTimeout(5, TimeUnit.SECONDS) + .create()) { + for (int i = 0; i < 5; i++) { + producer.newMessage().value(payload).send(); + totalBytes += testSize; + } + } + + long bwEnd = System.nanoTime(); + double seconds = (bwEnd - bwStart) / 1_000_000_000.0; + double mbps = (totalBytes / 1024.0 / 1024.0) / seconds; + + result.put("bandwidthMBps", mbps); + } + + result.put("diagnosticsLevel", testType); + return createSuccessResult("Connection diagnostics (" + testType + ") completed", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Error in connection diagnostics", e); + result.put("diagnosticsLevel", testType); + return createErrorResult("Diagnostics failed: " + e.getMessage()); + } + }).build() + ); + } + + private void registerHealthCheck(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "health-check", + "Check Pulsar cluster, topic, and subscription health status", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name to check (optional)" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to check (optional, requires topic)" + } + } + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + Map result = new HashMap<>(); + try { + try { + pulsarAdmin.brokers().getLeaderBroker(); + result.put("brokerHealthy", true); + } catch (Exception e) { + result.put("brokerHealthy", false); + return createErrorResult("Broker is not reachable: " + e.getMessage()); + } + + String topic = getStringParam(request.arguments(), "topic"); + String subscriptionName = getStringParam(request.arguments(), "subscriptionName"); + + if (topic != null && !topic.isEmpty()) { + topic = buildFullTopicName(request.arguments()); + TopicStats stats = pulsarAdmin.topics().getStats(topic); + + double throughputMBps = (stats.getMsgThroughputIn() + + stats.getMsgThroughputOut()) / (1024.0 * 1024.0); + double messagesPerSecond = (stats.getMsgRateIn() + stats.getMsgRateOut()); + + result.put("topic", topic); + result.put("throughputMBps", throughputMBps); + result.put("messagesPerSecond", messagesPerSecond); + + long backlog = stats.getSubscriptions().values().stream() + .mapToLong(sub -> sub.getMsgBacklog()) + .sum(); + result.put("backlog", backlog); + + String backlogLevel; + if (backlog == 0) { + backlogLevel = "EMPTY"; + } else if (backlog < 1000) { + backlogLevel = "LOW"; + } else if (backlog < 100000) { + backlogLevel = "MEDIUM"; + } else { + backlogLevel = "HIGH"; + } + result.put("backlogLevel", backlogLevel); + + if (subscriptionName != null && stats.getSubscriptions().containsKey(subscriptionName)) { + SubscriptionStats subStats = stats.getSubscriptions().get(subscriptionName); + result.put("subscriptionName", subscriptionName); + result.put("subscriptionBacklog", subStats.getMsgBacklog()); + result.put("subscriptionMsgRateOut", subStats.getMsgRateOut()); + result.put("subscriptionMsgRateRedeliver", subStats.getMsgRateRedeliver()); + } + + boolean isHealthy = result.get("brokerHealthy").equals(true) + && throughputMBps > 0 + && !"HIGH".equals(backlogLevel); + result.put("isHealthy", isHealthy); + + addTopicBreakdown(result, topic); + } + + return createSuccessResult("Health check completed", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Unexpected error in health check", e); + return createErrorResult("Unexpected error: " + e.getMessage()); + } + }).build() + ); + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/NamespaceTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/NamespaceTools.java new file mode 100644 index 0000000..b8ee7e3 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/NamespaceTools.java @@ -0,0 +1,692 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.common.policies.data.BacklogQuota; +import org.apache.pulsar.common.policies.data.RetentionPolicies; + +public class NamespaceTools extends BasePulsarTools { + + public NamespaceTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer) { + registerListNamespaces(mcpServer); + registerGetNamespaceInfo(mcpServer); + registerCreateNamespace(mcpServer); + registerDeleteNamespace(mcpServer); + registerSetRetentionPolicy(mcpServer); + registerGetRetentionPolicy(mcpServer); + registerSetBacklogQuota(mcpServer); + registerGetBacklogQuota(mcpServer); + registerClearNamespaceBacklog(mcpServer); + registerNamespaceStats(mcpServer); + } + + private void registerListNamespaces(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-namespaces", + "List all namespaces under a given tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The name of the tenant" + } + }, + "required": [] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getStringParam(request.arguments(), "tenant"); + if (tenant != null) { + tenant = tenant.trim(); + } + List namespaces; + if (tenant != null && !tenant.trim().isEmpty()) { + namespaces = pulsarAdmin.namespaces().getNamespaces(tenant); + if (namespaces == null) { + namespaces = List.of(); + } + } else { + List tenants = pulsarAdmin.tenants().getTenants(); + if (tenants == null) { + tenants = List.of(); + } + namespaces = new ArrayList<>(); + for (String namespace : tenants) { + try { + namespaces.addAll(pulsarAdmin.namespaces().getNamespaces(namespace)); + } catch (Exception e) { + LOGGER.warn("Failed to get namespaces for tenant " + namespace, e); + } + } + } + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespaces", namespaces); + result.put("count", namespaces.size()); + + return createSuccessResult("Namespaces retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to list namespaces", e); + return createErrorResult("Failed to list namespaces: " + e.getMessage()); + } + }).build()); + } + + private void registerGetNamespaceInfo(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-namespace-info", + "Get detailed info of a namespace under a tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Tenant name (default: 'public')", + "default": "public" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + Map details = new HashMap<>(); + details.put("policies", pulsarAdmin.namespaces().getPolicies(fullNamespace)); + details.put("backlogQuotaMap", pulsarAdmin.namespaces().getBacklogQuotaMap(fullNamespace)); + details.put("retention", pulsarAdmin.namespaces().getRetention(fullNamespace)); + details.put("persistence", pulsarAdmin.namespaces().getPersistence(fullNamespace)); + details.put("maxConsumersPerSubscription", + pulsarAdmin.namespaces(). + getMaxConsumersPerSubscription(fullNamespace)); + details.put("maxConsumersPerTopic", + pulsarAdmin.namespaces(). + getMaxConsumersPerTopic(fullNamespace)); + details.put("maxProducersPerTopic", + pulsarAdmin.namespaces(). + getMaxProducersPerTopic(fullNamespace)); + + return createSuccessResult("Namespace info retrieved successfully", details); + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get namespace info", e); + return createErrorResult("Failed to get namespace info: " + e.getMessage()); + } + }).build()); + } + + private void registerCreateNamespace(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "create-namespace", + "Create a new namespace under a given tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Tenant name (default: 'public')", + "default": "public" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": ["namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + pulsarAdmin.namespaces().createNamespace(fullNamespace); + + String[] parts = fullNamespace.split("/"); + String tenant = parts[0]; + String namespace = parts[1]; + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + result.put("created", true); + + return createSuccessResult("Namespace created successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to create namespace", e); + return createErrorResult("Failed to create namespace: " + e.getMessage()); + } + }).build()); + } + + private void registerDeleteNamespace(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-namespace", + "Delete a namespace under a given tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Tenant name (default: 'public')", + "default": "public" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "force": { + "type": "boolean", + "description": "Whether to force deletion (default: false)", + "default": false + } + }, + "required": ["namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + boolean force = getBooleanParam(request.arguments(), "force", false); + + String[] parts = fullNamespace.split("/"); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + if (isSystemNamespace(namespace)) { + return createErrorResult("Cannot delete system namespace: " + namespace); + } + + pulsarAdmin.namespaces().deleteNamespace(fullNamespace, force); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + result.put("force", force); + result.put("deleted", true); + + return createSuccessResult("Namespace deleted successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to delete namespace", e); + return createErrorResult("Failed to delete namespace: " + e.getMessage()); + } + }).build()); + } + + private void registerSetRetentionPolicy(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "set-retention-policy", + "Set the retention policy for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Tenant name (default: 'public')", + "default": "public" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "retentionTimeInMinutes": { + "type": "integer", + "description": "How long to retain data in minutes", + "minimum": 0 + }, + "retentionSizeInMB": { + "type": "integer", + "description": "How much data to retain in MB", + "minimum": 0 + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + Integer retentionTime = getIntParam(request.arguments(), "retentionTimeInMinutes", -1); + Integer retentionSize = getIntParam(request.arguments(), "retentionSizeInMB", -1); + if (retentionTime == null) { + retentionTime = -1; + } + if (retentionSize == null) { + retentionSize = -1; + } + + RetentionPolicies policies = new RetentionPolicies(retentionTime, retentionSize); + pulsarAdmin.namespaces().setRetention(fullNamespace, policies); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + result.put("retentionTime", retentionTime); + result.put("retentionSize", retentionSize); + + return createSuccessResult("Retention policy set successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to set retention policy", e); + return createErrorResult("Failed to set retention policy: " + e.getMessage()); + } + }).build()); + } + + private void registerGetRetentionPolicy(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-retention-policy", + "Get the retention policy for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + RetentionPolicies policies = pulsarAdmin.namespaces().getRetention(fullNamespace); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + + if (policies != null) { + result.put("retentionTimeInMinutes", policies.getRetentionTimeInMinutes()); + result.put("retentionSizeInMB", policies.getRetentionSizeInMB()); + } else { + result.put("message", "No retention policy configured for this namespace."); + } + + return createSuccessResult("Retention policy fetched successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get retention policy", e); + return createErrorResult("Failed to get retention policy: " + e.getMessage()); + } + }).build()); + } + + private void registerSetBacklogQuota(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "set-backlog-quota", + "Set backlog quota for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "limitSizeInBytes": { + "type": "integer", + "description": "Backlog quota limit in bytes", + "minimum": 0 + }, + "policy": { + "type": "string", + "description": "policy: producer_request_hold, producer_exception, or consumer_eviction" + } + }, + "required": ["tenant", "namespace", "limitSizeInBytes", "policy"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + Integer limitSize = getIntParam(request.arguments(), "limitSizeInBytes", 0); + String policyStr = getRequiredStringParam(request.arguments(), "policy"); + + if (limitSize == null || limitSize <= 0) { + return createErrorResult("Limit size must be greater than 0."); + } + + BacklogQuota.RetentionPolicy policy; + switch (policyStr.toLowerCase()) { + case "producer_request_hold": + policy = BacklogQuota.RetentionPolicy.producer_request_hold; + break; + case "producer_exception": + policy = BacklogQuota.RetentionPolicy.producer_exception; + break; + case "consumer_backlog_eviction": + policy = BacklogQuota.RetentionPolicy.consumer_backlog_eviction; + break; + default: + return createErrorResult("Invalid policy:" + + policyStr, + List.of("Valid policies: producer_request_hold, " + + "producer_exception, consumer_backlog_eviction")); + } + + BacklogQuota quota = BacklogQuota.builder() + .limitSize(limitSize) + .retentionPolicy(policy) + .build(); + + pulsarAdmin.namespaces().setBacklogQuota(fullNamespace, quota); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + result.put("limitSizeInBytes", limitSize); + result.put("policy", policy.name()); + + return createSuccessResult("Backlog quota set successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to set backlog quota", e); + return createErrorResult("Failed to set backlog quota: " + e.getMessage()); + } + }).build()); + } + + private void registerGetBacklogQuota(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-backlog-quota", + "Get backlog quota for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + Map quotas = + pulsarAdmin.namespaces().getBacklogQuotaMap(fullNamespace); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + + if (quotas == null || quotas.isEmpty()) { + result.put("message", "No backlog quota configured"); + result.put("quotas", Map.of()); + } else { + Map quotaInfo = new HashMap<>(); + quotas.forEach((type, quota) -> { + Map info = new HashMap<>(); + info.put("limitSize", quota.getLimitSize()); + info.put("policy", quota.getPolicy().toString()); + quotaInfo.put(type.toString(), info); + }); + result.put("quotas", quotaInfo); + } + + return createSuccessResult("Backlog quota retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get backlog quota", e); + return createErrorResult("Failed to get backlog quota: " + e.getMessage()); + } + }).build()); + } + + private void registerClearNamespaceBacklog(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "clear-namespace-backlog", + "Clear the backlog for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "subscriptionName": { + "type": "string", + "description": "clear backlog only for this subscription" + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + String subscriptionName = getStringParam( + request.arguments(), + "subscriptionName"); + + if (subscriptionName != null && !subscriptionName.trim().isEmpty()) { + pulsarAdmin.namespaces() + .clearNamespaceBacklogForSubscription( + fullNamespace, subscriptionName); + } else { + pulsarAdmin.namespaces().clearNamespaceBacklog(fullNamespace); + } + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespace", namespace); + + return createSuccessResult("Namespace backlog cleared successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to clear namespace backlog", e); + return createErrorResult("Failed to clear namespace backlog: " + e.getMessage()); + } + }).build()); + } + + private void registerNamespaceStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-namespace-stats", + "Get statistics for a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": ["tenant", "namespace"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String fullNamespace = resolveNamespace(request.arguments()); + + String[] parts = fullNamespace.split("/", 2); + String tenant = parts.length > 0 ? parts[0] : ""; + String namespace = parts.length > 1 ? parts[1] : ""; + + List topics = pulsarAdmin.topics().getList(fullNamespace); + if (topics == null) { + topics = List.of(); + } + + long persistentTopics = topics.stream().filter(t -> + t.startsWith("persistent://")).count(); + long nonPersistentTopics = topics.stream().filter(t -> + t.startsWith("non-persistent://")).count(); + + Map result = new HashMap<>(); + result.put("namespace", namespace); + result.put("tenant", tenant); + result.put("persistentTopics", persistentTopics); + result.put("nonPersistentTopics", nonPersistentTopics); + result.put("topics", topics); + + return createSuccessResult("Namespace stats retrieved successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get namespace stats", e); + return createErrorResult("Failed to get namespace stats: " + e.getMessage()); + } + }).build()); + } + + private boolean isSystemNamespace(String namespace) { + return namespace.equals("public/default") + || namespace.equals("public/functions") + || namespace.equals("public/system"); + } + +} \ No newline at end of file diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SchemaTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SchemaTools.java new file mode 100644 index 0000000..163a0c9 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SchemaTools.java @@ -0,0 +1,415 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.common.schema.SchemaInfo; +import org.apache.pulsar.common.schema.SchemaType; + +public class SchemaTools extends BasePulsarTools{ + + public SchemaTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer) { + registerGetSchemaInfo(mcpServer); + registerGetSchemaVersion(mcpServer); + registerAllSchemaVersions(mcpServer); + registerDeleteSchema(mcpServer); + registerTestSchemaCompatibility(mcpServer); + registerUploadSchema(mcpServer); + } + + private void registerGetSchemaInfo(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-schema-info", + "Get schema info for a specific topic", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + } + }, + "required": ["tenant", "namespace", "topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + SchemaInfo schemaInfo = pulsarAdmin.schemas().getSchemaInfo(topic); + Map result = new HashMap<>(); + result.put("schemaType", schemaInfo.getType().name()); + result.put("schema", Base64.getEncoder().encodeToString(schemaInfo.getSchema())); + result.put("properties", schemaInfo.getProperties()); + + return createSuccessResult("Schema info retrieved successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get schema info", e); + return createErrorResult("Failed to get schema info: " + e.getMessage()); + } + }).build()); + } + + private void registerAllSchemaVersions(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-schema-AllVersions", + "Get schema all versions", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders', full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + List versions = pulsarAdmin.schemas().getAllSchemas(topic); + List> versionList = new ArrayList<>(); + + for (int i = 0; i < versions.size(); i++) { + SchemaInfo schemaInfo = versions.get(i); + Map versionMap = new HashMap<>(); + versionMap.put("versionIndex", i); + versionMap.put("type", schemaInfo.getType().name()); + versionMap.put("schema", new String(schemaInfo.getSchema())); + versionMap.put("properties", schemaInfo.getProperties()); + versionList.add(versionMap); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("schemaVersions", versionList); + + addTopicBreakdown(result, topic); + return createSuccessResult("Schema versions retrieved", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Schema not found for topic"); + } catch (PulsarAdminException e) { + LOGGER.error("Failed to get schema version for topic", e); + return createErrorResult("Failed to get schema version: " + e.getMessage()); + } + }).build()); + } + + private void registerGetSchemaVersion(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-schema-version", + "Get a specific schema version of a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "versionIndex": { + "type": "integer", + "description": "Index of the schema version to retrieve (0-based)" + } + }, + "required": ["topic", "versionIndex"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + int versionIndex = getIntParam(request.arguments(), "versionIndex", 0); + + List schemaInfos = pulsarAdmin + .schemas() + .getAllSchemas(topic); + if (versionIndex < 0 || versionIndex >= schemaInfos.size()) { + return createErrorResult("Invalid versionIndex: " + + versionIndex + + ", available versions: " + + schemaInfos.size()); + } + + SchemaInfo schemaInfo = schemaInfos.get(versionIndex); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("versionIndex", versionIndex); + result.put("type", schemaInfo.getType().toString()); + result.put("schema", new String(schemaInfo.getSchema())); + result.put("properties", schemaInfo.getProperties()); + result.put("name", schemaInfo.getName()); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Fetched schema version " + versionIndex + " successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (PulsarAdminException e) { + LOGGER.error("Failed to fetch schema version for topic", e); + return createErrorResult("Failed to fetch schema version: " + e.getMessage()); + } + }).build()); + } + + private void registerUploadSchema(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "upload-schema", + "Upload a new schema to a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "schema": { + "type": "string", + "description": "Schema content (usually JSON or AVRO schema string)" + }, + "schemaType": { + "type": "string", + "description": "Schema type (e.g., AVRO, JSON, STRING)", + "enum": ["AVRO", "JSON", "STRING", "PROTOBUF", "KEY_VALUE", "BYTES"] + }, + "properties": { + "type": "object", + "description": "Optional schema properties (key-value map)" + } + }, + "required": ["topic", "schema", "schemaType"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String schemaStr = getRequiredStringParam(request.arguments(), "schema"); + String schemaTypeStr = getRequiredStringParam(request.arguments(), "schemaType"); + + SchemaType schemaType; + try { + schemaType = SchemaType.valueOf(schemaTypeStr.toUpperCase()); + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid schema type: " + schemaTypeStr, + List.of("Valid types: AVRO, JSON, STRING, PROTOBUF, BYTES")); + } + + Map props = null; + Object pObj = request.arguments().get("properties"); + if (pObj instanceof Map m) { + props = new HashMap<>(); + for (Map.Entry en : m.entrySet()) { + if (en.getKey() != null && en.getValue() != null) { + props.put(String.valueOf(en.getKey()), String.valueOf(en.getValue())); + } + } + } + + SchemaInfo schemaInfo = SchemaInfo.builder() + .name(topic) + .type(schemaType) + .schema(schemaStr.getBytes(StandardCharsets.UTF_8)) + .properties(props) + .build(); + + pulsarAdmin.schemas().createSchema(topic, schemaInfo); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("schema", schemaStr); + result.put("schemaType", schemaTypeStr); + result.put("uploaded", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Schema uploaded successfully to topic: " + topic, null); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid schemaType: " + e.getMessage()); + } catch (PulsarAdminException e) { + LOGGER.error("Failed to upload schema to topic", e); + return createErrorResult("PulsarAdminException: " + e.getMessage()); + } + }).build() + ); + } + + private void registerDeleteSchema(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-schema", + "Delete the schema of a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "force": { + "type": "boolean", + "description": "Force delete schema", + "default": false + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + Boolean force = getBooleanParam(request.arguments(), "force", false); + + pulsarAdmin.schemas().deleteSchema(topic, force); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("deleted", true); + result.put("force", force); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Schema deleted successfully from topic: " + topic, result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (PulsarAdminException e) { + LOGGER.error("Failed to delete schema from topic", e); + return createErrorResult("PulsarAdminException: " + e.getMessage()); + } + }).build() + ); + } + + private void registerTestSchemaCompatibility(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "test-schema-compatibility", + "Test if a schema is compatible with the existing schema of a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:orders or full:persistent://public/default/orders)" + }, + "schema": { + "type": "string", + "description": "Schema content to test (usually JSON or AVRO schema string)" + }, + "schemaType": { + "type": "string", + "description": "Schema type (e.g., AVRO, JSON, STRING)", + "enum": ["AVRO", "JSON", "STRING", "PROTOBUF", "KEY_VALUE", "BYTES"] + } + }, + "required": ["topic", "schema", "schemaType"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String schemaStr = getRequiredStringParam(request.arguments(), "schema"); + String schemaTypeStr = getRequiredStringParam(request.arguments(), "schemaType"); + + SchemaType schemaType; + try { + schemaType = SchemaType.valueOf(schemaTypeStr.toUpperCase()); + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid schema type: " + schemaTypeStr, + List.of("Valid types: AVRO, JSON, STRING, PROTOBUF, BYTES")); + } + + SchemaInfo schemaInfo = SchemaInfo.builder() + .name(topic) + .type(schemaType) + .schema(schemaStr.getBytes(StandardCharsets.UTF_8)) + .build(); + + boolean isCompatible = pulsarAdmin.schemas() + .testCompatibility(topic, schemaInfo).isCompatibility(); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("isCompatible", isCompatible); + result.put("schemaType", schemaType); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Compatibility test result: " + isCompatible, result); + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (PulsarAdminException e) { + LOGGER.error("Failed to test schema compatibility", e); + return createErrorResult("PulsarAdminException: " + e.getMessage()); + } + }).build() + ); + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SubscriptionTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SubscriptionTools.java new file mode 100644 index 0000000..b054a22 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/SubscriptionTools.java @@ -0,0 +1,679 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.common.policies.data.SubscriptionStats; +import org.apache.pulsar.common.policies.data.TopicStats; + +public class SubscriptionTools extends BasePulsarTools{ + + public SubscriptionTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer) { + registerListSubscriptions(mcpServer); + registerGetSubscriptionStats(mcpServer); + registerCreateSubscription(mcpServer); + registerDeleteSubscription(mcpServer); + registerSkipMessages(mcpServer); + registerResetSubscriptionCursor(mcpServer); + registerExpireSubscriptionMessages(mcpServer); + registerUnsubscribe(mcpServer); + registerListSubscriptionConsumers(mcpServer); + registerGetSubscriptionCursorPositions(mcpServer); + + } + + private void registerListSubscriptions(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-subscriptions", + "List all subscriptions for a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + List subscriptions = pulsarAdmin.topics().getSubscriptions(topic); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptions", subscriptions); + result.put("subscriptionCount", subscriptions.size()); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscriptions listed successfully", result); + + } catch (Exception e) { + LOGGER.error("Failed to list subscriptions", e); + return createErrorResult("Failed to list subscriptions: " + e.getMessage()); + } + }) + .build()); + } + + private void registerGetSubscriptionStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-subscription-stats", + "Get statistics of a subscription for a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription" + } + }, + "required": ["topic", "subscription"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta.partitions > 0) { + return createErrorResult("Please specify a concrete partition, e.g. topic-partition-0"); + } + + TopicStats stats = pulsarAdmin.topics().getStats(topic); + SubscriptionStats subStats = stats.getSubscriptions().get(subscription); + if (subStats == null) { + return createErrorResult("Subscription not found: " + subscription); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscription", subscription); + result.put("msgBacklog", subStats.getMsgBacklog()); + result.put("msgRateOut", subStats.getMsgRateOut()); + result.put("msgThroughputOut", subStats.getMsgThroughputOut()); + result.put("msgRateRedeliver", + subStats.getMsgRateRedeliver()); + result.put("type", subStats.getType()); + result.put("consumerCount", + subStats.getConsumers() != null + ? subStats.getConsumers().size() + : 0); + result.put("isReplicated", subStats.isReplicated()); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription stats fetched successfully", result); + + } catch (Exception e) { + LOGGER.error("Failed to get subscription stats", e); + return createErrorResult("Failed to get subscription stats: " + e.getMessage()); + } + }) + .build()); + } + + private void registerCreateSubscription(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "create-subscription", + "Create a subscription on a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription to create from an existing topic" + }, + "messageId": { + "type": "string", + "default": "latest", + "description": "Initial position of the subscription (optional, defaults to latest)" + } + }, + "required": ["topic", "subscription"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + String messageId = getStringParam(request.arguments(), "messageId"); + + String pos = (messageId == null ? "latest" : messageId.trim().toLowerCase()); + if (!pos.equals("latest") && !pos.equals("earliest")) { + return createErrorResult("messageId must be 'latest' or 'earliest'"); + } + + MessageId initial = pos.equals("earliest") ? MessageId.earliest : MessageId.latest; + pulsarAdmin.topics().createSubscription(topic, subscription, initial); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscription", subscription); + result.put("messageId", messageId != null ? messageId : "latest"); + result.put("created", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription created successfully", result); + + } catch (Exception e) { + LOGGER.error("Failed to create subscription", e); + return createErrorResult("Failed to create subscription: " + e.getMessage()); + } + }) + .build() + ); + } + + private void registerDeleteSubscription(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-subscription", + "Delete a subscription from a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription to delete" + } + }, + "required": ["topic", "subscription"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + + pulsarAdmin.topics().deleteSubscription(topic, subscription); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscription", subscription); + result.put("deleted", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription deleted successfully", result); + + } catch (Exception e) { + LOGGER.error("Failed to delete subscription", e); + return createErrorResult("Failed to delete subscription: " + e.getMessage()); + } + }).build()); + } + + private void registerSkipMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "skip-messages", + "Skip messages for a subscription on a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription" + }, + "numMessages": { + "type": "integer", + "description": "Number of messages to skip", + "minimum": 1 + } + }, + "required": ["topic", "subscription"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + int numMessages = getIntParam(request.arguments(), "numMessages", 1); + + if (numMessages <= 0) { + return createErrorResult("Number of messages must be greater than 0."); + } + + pulsarAdmin.topics().skipMessages(topic, subscription, numMessages); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscription", subscription); + result.put("numMessagesSkipped", numMessages); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Skipped messages successfully", result); + + } catch (Exception e) { + LOGGER.error("Failed to skip messages", e); + return createErrorResult("Failed to skip messages: " + e.getMessage()); + } + }).build()); + } + + private void registerResetSubscriptionCursor(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "reset-subscription-cursor", + "Reset a subscription cursor to a specific message publish time (timestamp in ms)", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscriptionName": { + "type": "string", + "description": "The name of the subscription to reset" + }, + "timestamp": { + "type": "integer", + "description": "Timestamp (ms since epoch) to reset the subscription cursor", + "default": 0 + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + Long timestamp = getLongParam(request.arguments(), "timestamp", 0L); + if (timestamp <= 0) { + timestamp = 0L; + } + pulsarAdmin.topics().resetCursor(topic, subscriptionName, timestamp); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("timestamp", timestamp); + result.put("reset", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription cursor reset successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to reset subscription cursor", e); + return createErrorResult("Failed to reset subscription cursor: " + e.getMessage()); + } + }).build()); + } + + private void registerExpireSubscriptionMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "expire-subscription-messages", + "Expire messages for a subscription older than the given seconds", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscriptionName": { + "type": "string", + "description": "The name of the subscription whose messages will be expired" + }, + "expireTimeSeconds": { + "type": "integer", + "description": "Expire messages older than this time in seconds", + "default": "0", + "minimum": 0 + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + Integer expireTimeSeconds = getIntParam(request.arguments(), "expireTimeSeconds", 0); + + pulsarAdmin.topics().expireMessages(topic, subscriptionName, expireTimeSeconds); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("expireTimeSeconds", expireTimeSeconds); + result.put("expired", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult( + "Expired subscription messages up to message ID successfully" + , result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to expire subscription messages", e); + return createErrorResult("Failed to expire subscription messages: " + e.getMessage()); + } + }).build()); + } + + private void registerUnsubscribe(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "unsubscribe", + "Unsubscribe a subscription from a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "subscriptionName": { + "type": "string", + "description": "The name of the subscription to unsubscribe" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + + pulsarAdmin.topics().deleteSubscription(topic, subscriptionName); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("unsubscribed", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription unsubscribed successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to unsubscribe subscription", e); + return createErrorResult("Failed to unsubscribe: " + e.getMessage()); + } + }).build()); + } + + private void registerListSubscriptionConsumers(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-subscription-consumers", + "List consumers of a subscription, with per-consumer metrics;", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to inspect" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscriptionName"); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscription); + result.put("timestamp", System.currentTimeMillis()); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + List> consumers = new ArrayList<>(); + + if (meta.partitions > 0) { + var ps = pulsarAdmin.topics().getPartitionedStats(topic, true); + ps.getPartitions().forEach((partition, ts) -> { + var subStats = ts.getSubscriptions() != null + ? ts.getSubscriptions().get(subscription) : null; + if (subStats != null && subStats.getConsumers() != null) { + for (var c : subStats.getConsumers()) { + Map one = new HashMap<>(); + one.put("partition", partition); + one.put("consumerName", c.getConsumerName()); + one.put("address", c.getAddress()); + one.put("connectedSince", c.getConnectedSince()); + one.put("msgRateOut", c.getMsgRateOut()); + one.put("msgThroughputOut", c.getMsgThroughputOut()); + one.put("availablePermits", c.getAvailablePermits()); + one.put("unackedMessages", c.getUnackedMessages()); + consumers.add(one); + } + } + }); + } else { + var stats = pulsarAdmin.topics().getStats(topic); + var subStats = stats.getSubscriptions() != null + ? stats.getSubscriptions().get(subscription) : null; + if (subStats == null) { + return createErrorResult("Subscription not found: " + subscription); + } + if (subStats.getConsumers() != null) { + for (var c : subStats.getConsumers()) { + Map one = new HashMap<>(); + one.put("consumerName", c.getConsumerName()); + one.put("address", c.getAddress()); + one.put("connectedSince", c.getConnectedSince()); + one.put("msgRateOut", c.getMsgRateOut()); + one.put("msgThroughputOut", c.getMsgThroughputOut()); + one.put("availablePermits", c.getAvailablePermits()); + one.put("unackedMessages", c.getUnackedMessages()); + consumers.add(one); + } + } + } + + result.put("consumerCount", consumers.size()); + result.put("consumers", consumers); + + addTopicBreakdown(result, topic); + return createSuccessResult("Subscription consumers retrieved", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to list subscription consumers", e); + return createErrorResult("Failed to list subscription consumers: " + e.getMessage()); + } + }) + .build()); + } + + private void registerGetSubscriptionCursorPositions(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-subscription-cursor-positions", + "Get cursor positions (markDelete/read) of a subscription; supports partitioned topics", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to inspect" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscriptionName"); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscription); + result.put("timestamp", System.currentTimeMillis()); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + Map positions = new LinkedHashMap<>(); + int found = 0; + + if (meta.partitions > 0) { + var ps = pulsarAdmin.topics().getPartitionedStats(topic, true); + for (String partition : ps.getPartitions().keySet()) { + try { + var internal = pulsarAdmin.topics().getInternalStats(partition); + if (internal != null && internal.cursors != null + && internal.cursors.containsKey(subscription)) { + var cur = internal.cursors.get(subscription); + Map info = new HashMap<>(); + info.put("markDeletePosition", cur.markDeletePosition); + info.put("readPosition", cur.readPosition); + info.put("messagesConsumedCounter", cur.messagesConsumedCounter); + positions.put(partition, info); + found++; + } else { + positions.put(partition, + Map.of("message", "cursor not found on this partition")); + } + } catch (Exception ie) { + positions.put(partition, Map.of("error", ie.getMessage())); + } + } + } else { + var internal = pulsarAdmin.topics().getInternalStats(topic); + if (internal != null && internal.cursors != null + && internal.cursors.containsKey(subscription)) { + var cur = internal.cursors.get(subscription); + Map info = new HashMap<>(); + info.put("markDeletePosition", cur.markDeletePosition); + info.put("readPosition", cur.readPosition); + info.put("messagesConsumedCounter", cur.messagesConsumedCounter); + positions.put(topic, info); + found = 1; + } else { + return createErrorResult("Cursor not found for subscription: " + subscription); + } + } + + result.put("foundOnPartitions", found); + result.put("positions", positions); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Subscription cursor positions retrieved", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get subscription cursor positions", e); + return createErrorResult("Failed to get subscription cursor positions: " + e.getMessage()); + } + }) + .build()); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TenantTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TenantTools.java new file mode 100644 index 0000000..2b26bea --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TenantTools.java @@ -0,0 +1,474 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.common.policies.data.TenantInfo; + +public class TenantTools extends BasePulsarTools { + + public TenantTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer) { + registerListTenant(mcpServer); + registerGetTenantInfo(mcpServer); + registerCreateTenant(mcpServer); + registerUpdateTenant(mcpServer); + registerDeleteTenant(mcpServer); + registerGetTenantStats(mcpServer); + } + + private void registerListTenant(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-tenants", + "List all Pulsar tenants", + """ + { + "type": "object", + "properties": {}, + "required": [] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + List tenants = pulsarAdmin.tenants().getTenants(); + Map result = Map.of( + "tenants", tenants, + "count", tenants.size() + ); + return createSuccessResult("Tenant list retrieved successfully", result); + } catch (Exception e) { + LOGGER.error("Failed to list tenants", e); + return createErrorResult("Failed to list tenants", List.of(safeErrorMessage(e))); + } + }).build() + ); + } + + private void registerGetTenantInfo(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-tenant-info", + "Get information about a Pulsar tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The name of the tenant" + } + }, + "required": ["tenant"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getRequiredStringParam(request.arguments(), "tenant"); + TenantInfo tenantInfo = pulsarAdmin.tenants().getTenantInfo(tenant); + Map result = Map.of( + "tenant", tenant, + "allowedClusters", tenantInfo.getAllowedClusters(), + "adminRoles", tenantInfo.getAdminRoles() + ); + return createSuccessResult("Tenant info retrieved successfully", result); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Tenant not found", List.of("Tenant does not exist")); + } catch (Exception e) { + LOGGER.error("Failed to get tenant info", e); + return createErrorResult("Failed to get tenant info", List.of(safeErrorMessage(e))); + } + }).build() + ); + } + + private void registerCreateTenant(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "create-tenant", + "Create a new Pulsar tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The name of the tenant to create" + }, + "allowedClusters": { + "type": "array", + "items": { "type": "string" }, + "description": "List of clusters allowed for this tenant (optional)" + }, + "adminRoles": { + "type": "array", + "items": { "type": "string" }, + "description": "List of admin roles for this tenant (optional)" + } + }, + "required": ["tenant"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getRequiredStringParam(request.arguments(), "tenant"); + + try { + pulsarAdmin.tenants().getTenantInfo(tenant); + return createErrorResult("Tenant already exists: " + tenant, + List.of("Choose a different tenant name")); + } catch (PulsarAdminException.NotFoundException ignore) { + + } catch (PulsarAdminException e) { + return createErrorResult("Failed to verify tenant existence: " + e.getMessage()); + } + + Set adminRoles = getSetParam(request.arguments(), "adminRoles"); + Set allowedClusters = getSetParam(request.arguments(), "allowedClusters"); + + List availableClusters0; + try { + availableClusters0 = pulsarAdmin.clusters().getClusters(); + } catch (Exception ex) { + LOGGER.warn("Failed to get clusters", ex); + availableClusters0 = List.of(); + } + Set availableClusters = (availableClusters0 == null) + ? Set.of() + : new HashSet<>(availableClusters0); + + if (allowedClusters.isEmpty()) { + if (!availableClusters.isEmpty()) { + allowedClusters = Set.copyOf(availableClusters); + } else { + allowedClusters = Set.of("standalone"); + } + } else { + if (!availableClusters.isEmpty()) { + Set invalid = new HashSet<>(allowedClusters); + invalid.removeAll(availableClusters); + if (!invalid.isEmpty()) { + return createErrorResult("Invalid clusters in allowedClusters: " + invalid); + } + } + } + + TenantInfo tenantInfo = TenantInfo.builder() + .adminRoles(adminRoles) + .allowedClusters(allowedClusters) + .build(); + + pulsarAdmin.tenants().createTenant(tenant, tenantInfo); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("allowedClusters", allowedClusters); + result.put("adminRoles", adminRoles == null ? Set.of() : adminRoles); + result.put("created", true); + + return createSuccessResult("Tenant created successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to create tenant", e); + return createErrorResult("Failed to create tenant", List.of(safeErrorMessage(e))); + } + }).build()); + } + + private void registerDeleteTenant(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-tenant", + "Delete a specific Pulsar tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Name of the tenant to delete" + }, + "force": { + "type": "boolean", + "description": "Whether to force delete the tenant even if it has namespaces", + "default": false + } + }, + "required": ["tenant"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getRequiredStringParam(request.arguments(), "tenant"); + boolean force = getBooleanParam(request.arguments(), "force", false); + + try { + pulsarAdmin.tenants().getTenantInfo(tenant); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Tenant not found: " + tenant); + } + + if (isSystemTenant(tenant)) { + return createErrorResult( + "System tenant cannot be deleted", + List.of("Tenant: " + tenant) + ); + } + + if (!force) { + List namespaces = pulsarAdmin.namespaces().getNamespaces(tenant); + if (namespaces != null && !namespaces.isEmpty()) { + return createErrorResult( + "Tenant has namespaces. Use 'force=true' to delete.", + List.of( + "Namespaces: " + namespaces, + "Set 'force' parameter to true to force deletion", + "Or manually delete all namespaces first" + ) + ); + } + } + + pulsarAdmin.tenants().deleteTenant(tenant); + + Map resultData = new HashMap<>(); + resultData.put("tenant", tenant); + resultData.put("force", force); + resultData.put("deleted", true); + + return createSuccessResult( + "Tenant deleted successfully", + resultData + ); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter", List.of(e.getMessage())); + } catch (Exception e) { + LOGGER.error("Failed to delete tenant", e); + String errorMessage = (e.getMessage() != null && !e.getMessage().isBlank()) + ? e.getMessage().split("\n")[0].trim() + : "Unknown error occurred"; + return createErrorResult("Failed to delete tenant", List.of(errorMessage)); + } + }).build()); + } + + private void registerUpdateTenant(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "update-tenant", + "Update the configuration of a specific Pulsar tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Name of the tenant to update" + }, + "adminRoles": { + "type": "array", + "items": { "type": "string" }, + "description": "List of new admin roles" + }, + "allowedClusters": { + "type": "array", + "items": { "type": "string" }, + "description": "List of allowed clusters for the tenant" + } + }, + "required": ["tenant"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getRequiredStringParam(request.arguments(), "tenant"); + + TenantInfo currentInfo; + try { + currentInfo = pulsarAdmin.tenants().getTenantInfo(tenant); + } catch (PulsarAdminException.NotFoundException e) { + return createErrorResult("Tenant not found: " + tenant + ". Create the tenant first."); + } + + Set currentRoles = currentInfo.getAdminRoles(); + Set currentAllowed = currentInfo.getAllowedClusters(); + if (currentRoles == null) { + currentRoles = Set.of(); + } + if (currentAllowed == null) { + currentAllowed = Set.of(); + } + + Set adminRoles = getSetParamOrDefault(request.arguments(), + "adminRoles", currentRoles); + Set allowedClusters = getSetParamOrDefault(request.arguments(), + "allowedClusters", currentAllowed); + + List availableClusters0 = pulsarAdmin.clusters().getClusters(); + Set availableClusters = (availableClusters0 == null) + ? Set.of() + : new HashSet<>(availableClusters0); + if (!allowedClusters.isEmpty() && !availableClusters.isEmpty()) { + Set invalid = new HashSet<>(allowedClusters); + invalid.removeAll(availableClusters); + if (!invalid.isEmpty()) { + return createErrorResult("Invalid clusters in allowedClusters: " + invalid); + } + } + + TenantInfo tenantInfo = TenantInfo.builder() + .adminRoles(adminRoles) + .allowedClusters(allowedClusters) + .build(); + + pulsarAdmin.tenants().updateTenant(tenant, tenantInfo); + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("adminRoles", adminRoles); + result.put("allowedClusters", allowedClusters); + result.put("updated", true); + + return createSuccessResult("Tenant updated successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to update tenant", e); + return createErrorResult("Failed to update tenant", List.of(safeErrorMessage(e))); + } + }).build()); + } + + private void registerGetTenantStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-tenant-stats", + "Get basic stats for a specific Pulsar tenant", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "Name of the tenant to get statistics for" + } + }, + "required": ["tenant"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String tenant = getRequiredStringParam(request.arguments(), "tenant"); + + List namespaces0 = pulsarAdmin.namespaces().getNamespaces(tenant); + List namespaces = (namespaces0 == null) ? List.of() : namespaces0; + + + int totalTopics = 0; + Map namespaceTopicCounts = new HashMap<>(); + + for (String namespace : namespaces) { + try { + List topics0 = pulsarAdmin.topics().getList(namespace); + List topics = (topics0 == null) ? List.of() : topics0; + namespaceTopicCounts.put(namespace, topics.size()); + totalTopics += topics.size(); + } catch (Exception e) { + LOGGER.warn("Failed to get topics for namespace {}", namespace, e); + namespaceTopicCounts.put(namespace, 0); + } + } + + Map result = new HashMap<>(); + result.put("tenant", tenant); + result.put("namespaceCount", namespaces.size()); + result.put("namespaces", namespaces); + result.put("totalTopics", totalTopics); + result.put("topicCounts", namespaceTopicCounts); + + return createSuccessResult("Tenant stats retrieved successfully", result); + + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get tenant stats", e); + return createErrorResult("Failed to get tenant stats", List.of(safeErrorMessage(e))); + } + }).build()); + } + + private Set getSetParam(Map args, String key) { + Object obj = args.get(key); + if (obj instanceof List list) { + return list.stream().filter(Objects::nonNull).map(String::valueOf).collect(Collectors.toSet()); + } + return Set.of(); + } + + private Set getSetParamOrDefault(Map args, String key, Set defaultValue) { + Set value = getSetParam(args, key); + return value.isEmpty() ? defaultValue : value; + } + + private String safeErrorMessage(Exception e) { + if (e.getMessage() == null || e.getMessage().isBlank()) { + return "Unknown error occurred"; + } + return e.getMessage().split("\n")[0].trim(); + } + + private boolean isSystemTenant(String tenant) { + return tenant.equals("pulsar") + || tenant.equals("public") + || tenant.equals("sample"); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TopicTools.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TopicTools.java new file mode 100644 index 0000000..9e2ff5d --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/TopicTools.java @@ -0,0 +1,950 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.common.policies.data.TopicStats; + +public class TopicTools extends BasePulsarTools { + + public TopicTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + public void registerTools(McpSyncServer mcpServer) { + registerListTopics(mcpServer); + registerCreateTopics(mcpServer); + registerDeleteTopics(mcpServer); + registerGetTopicStats(mcpServer); + registerGetTopicMetadata(mcpServer); + registerUpdateTopicPartitions(mcpServer); + registerCompactTopic(mcpServer); + registerUnloadTopic(mcpServer); + registerGetTopicBacklog(mcpServer); + registerExpireTopicMessages(mcpServer); + registerPeekTopicMessages(mcpServer); + registerResetTopicCursor(mcpServer); + registerGetTopicInternalStats(mcpServer); + registerGetPartitionedMetadata(mcpServer); + } + + private void registerListTopics(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "list-topics", + "List all topics under a specific namespace", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + } + }, + "required": [] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String namespace = resolveNamespace(request.arguments()); + + List topics = pulsarAdmin.topics().getList(namespace); + if (topics == null) { + topics = List.of(); + } + + Map result = new HashMap<>(); + result.put("namespace", namespace); + result.put("topics", topics); + result.put("count", topics.size()); + + return createSuccessResult("Topics listed successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to list topics", e); + return createErrorResult("Failed to list topics: " + e.getMessage()); + } + }).build()); + } + + private void registerCreateTopics(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "create-topics", + "Create one or more topics under a specific namespace", + """ + { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "topic": { + "type": "string", + "description": "Topic name('orders' or 'persistent://public/default/orders')" + }, + "persistent": { + "type": "boolean", + "description": "Whether topic should be persistent (default: true)", + "default": true + }, + "partitions": { + "type": "integer", + "description": "Number of partitions for each topic (0 means non-partitioned)", + "minimum": 0 + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + boolean persistent = getBooleanParam(request.arguments(), "persistent", true); + if (topic.startsWith("persistent://") && !persistent) { + topic = "non-" + topic; + } else if (topic.startsWith("non-persistent://") && persistent) { + topic = topic.replaceFirst("non-persistent://", "persistent://"); + } + + Integer partitions = getIntParam(request.arguments(), "partitions", 0); + + if (partitions > 0) { + pulsarAdmin.topics().createPartitionedTopic(topic, partitions); + } else { + pulsarAdmin.topics().createNonPartitionedTopic(topic); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("created", true); + result.put("partitions", partitions); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Topics created successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to create topics", e); + return createErrorResult("Failed to create topics: " + e.getMessage()); + } + }).build()); + } + + private void registerDeleteTopics(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "delete-topics", + "Delete one or more topics", + """ + { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": "The tenant name", + "default": "public" + }, + "namespace": { + "type": "string", + "description": "Namespace name or full path ('orders' or 'public/orders')", + "default": "default" + }, + "topic": { + "type": "string", + "description": "Topic name(simple:'orders' or full:'persistent://public/default/orders')" + }, + "force": { + "type": "boolean", + "description": "Force delete topic even if it has active subscriptions (default: false)", + "default": false + }, + "persistent": { + "type": "boolean", + "description": "Whether the topic is persistent (default: true)", + "default": true + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + Boolean force = getBooleanParam(request.arguments(), "force", false); + + var metadata = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (metadata != null && metadata.partitions > 0) { + pulsarAdmin.topics().deletePartitionedTopic(topic, force); + } else { + pulsarAdmin.topics().delete(topic, force); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("deleted", true); + result.put("force", force); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Topic deleted successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to delete topic", e); + return createErrorResult("Failed to delete topic: " + e.getMessage()); + } + }).build()); + } + + private void registerGetTopicStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-topic-stats", + "Get statistics for a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + Map result = new HashMap<>(); + result.put("topic", topic); + + if (meta != null && meta.partitions > 0) { + var ps = pulsarAdmin.topics().getPartitionedStats(topic, false); + result.put("msgRateIn", ps.getMsgRateIn()); + result.put("msgRateOut", ps.getMsgRateOut()); + result.put("msgThroughputIn", ps.getMsgThroughputIn()); + result.put("msgThroughputOut", ps.getMsgThroughputOut()); + result.put("storageSize", ps.getStorageSize()); + } else { + TopicStats stats = pulsarAdmin.topics().getStats(topic); + result.put("msgRateIn", stats.getMsgRateIn()); + result.put("msgRateOut", stats.getMsgRateOut()); + result.put("msgThroughputIn", stats.getMsgThroughputIn()); + result.put("msgThroughputOut", stats.getMsgThroughputOut()); + result.put("storageSize", stats.getStorageSize()); + result.put("subscriptions", stats.getSubscriptions()); // 可能为 null,直接透传 + result.put("publishers", stats.getPublishers()); + result.put("replication", stats.getReplication()); + } + + return createSuccessResult("Topic stats retrieved successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get topic stats", e); + return createErrorResult("Failed to get topic stats: " + e.getMessage()); + } + }).build()); + } + + private void registerGetTopicMetadata(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-topic-metadata", + "Get metadata information for a specific topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + var metadata = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + int partitions = (metadata == null) ? 0 : metadata.partitions; + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("partitions", partitions); + result.put("isPartitioned", partitions > 0); + + return createSuccessResult("Topic metadata fetched successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get topic metadata", e); + return createErrorResult("Failed to get topic metadata: " + e.getMessage()); + } + }).build()); + } + + private void registerUpdateTopicPartitions(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "update-topic-partitions", + "Update the number of partitions for a partitioned Pulsar topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name(simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "partitions": { + "type": "integer", + "description": "New number of partitions (must be greater than current partition count)", + "minimum": 1 + }, + "force": { + "type": "boolean", + "description": "Force update even if there are active consumers (default: false)", + "default": false + } + }, + "required": ["topic", "partitions"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + Integer partitions = getIntParam(request.arguments(), "partitions", 0); + + if (partitions <= 0) { + return createErrorResult("Invalid partitions parameter: " + + "must be at least 1"); + } + + var currentMetadata = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + int currentPartitions = currentMetadata.partitions; + + if (currentPartitions == 0) { + return createErrorResult("Topic is not partitioned. " + + "Use create-partitioned-topic to create a partitioned topic."); + } + + if (partitions <= currentPartitions) { + return createErrorResult("New partition count (" + + partitions + + ") must be greater than current partition count (" + + currentPartitions + + ")"); + } + + pulsarAdmin.topics().updatePartitionedTopic(topic, partitions); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("previousPartitions", currentPartitions); + result.put("newPartitions", partitions); + result.put("updated", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Topic partitions updated successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid input parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to update topic partitions", e); + return createErrorResult("Failed to update topic partitions: " + e.getMessage()); + } + }) + .build()); + } + + private void registerCompactTopic(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "compact-topic", + "Compact a specified topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta != null && meta.partitions > 0) { + for (int i = 0; i < meta.partitions; i++) { + pulsarAdmin.topics().triggerCompaction(topic + "-partition-" + i); + } + } else { + pulsarAdmin.topics().triggerCompaction(topic); + } + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("compactionTriggered", true); + addTopicBreakdown(result, topic); + + return createSuccessResult("Compaction triggered successfully for topic: ", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to compact topic", e); + return createErrorResult("Failed to compact topic: " + e.getMessage()); + } + }).build()); + } + + private void registerUnloadTopic(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "unload-topic", + "Unload a specified topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta != null && meta.partitions > 0) { + for (int i = 0; i < meta.partitions; i++) { + pulsarAdmin.topics().unload(topic + "-partition-" + i); + } + } else { + pulsarAdmin.topics().unload(topic); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("unloaded", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Topic unloaded successfully: ", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to unload topic", e); + return createErrorResult("Failed to unload topic: " + e.getMessage()); + } + }).build()); + } + + private void registerGetTopicBacklog(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-topic-backlog", + "Get the backlog size of a specified topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + TopicStats stats = pulsarAdmin.topics().getStats(topic); + + Map result = new HashMap<>(); + result.put("topic", topic); + + Map subscriptionBacklogs = new HashMap<>(); + long totalBacklog = 0; + + for (var entry : stats.getSubscriptions().entrySet()) { + String subscriptionName = entry.getKey(); + var subscriptionStats = entry.getValue(); + + long backlog = subscriptionStats.getBacklogSize(); + totalBacklog += backlog; + + Map subInfo = new HashMap<>(); + subInfo.put("backlog", backlog); + subInfo.put("type", subscriptionStats.getType()); + subInfo.put("consumers", subscriptionStats.getConsumers()); + + subscriptionBacklogs.put(subscriptionName, subInfo); + } + + result.put("totalBacklog", totalBacklog); + result.put("subscriptionBacklogs", subscriptionBacklogs); + result.put("subscriptionCount", stats.getSubscriptions().size()); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Topic backlog fetched successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get topic backlog", e); + return createErrorResult("Failed to get topic backlog: " + e.getMessage()); + } + }).build()); + } + + private void registerExpireTopicMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "expire-topic-messages", + "Expire messages for all subscriptions on a topic older than a given time", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "expireTimeInSeconds": { + "type": "integer", + "description": "Messages older than this number of seconds will be marked as expired", + "default": 0 + }, + "subscriptionName": { + "type": "string", + "description": "Subscription name to expire message for" + } + }, + "required": ["topic", "subscriptionName"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscriptionName = getRequiredStringParam(request.arguments(), "subscriptionName"); + Integer expireTimeInSeconds = getIntParam(request.arguments(), "expireTimeInSeconds", 0); + + if (expireTimeInSeconds == null || expireTimeInSeconds <= 0) { + return createErrorResult("expireTimeInSeconds must be > 0"); + } + + var meta = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + if (meta != null && meta.partitions > 0) { + for (int i = 0; i < meta.partitions; i++) { + pulsarAdmin.topics().expireMessages(topic + + "-partition-" + + i, subscriptionName, expireTimeInSeconds); + } + } else { + pulsarAdmin.topics().expireMessages(topic, subscriptionName, expireTimeInSeconds); + } + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("subscriptionName", subscriptionName); + result.put("expireTimeInSeconds", expireTimeInSeconds); + result.put("expired", true); + + addTopicBreakdown(result, topic); + + return createSuccessResult("Expired messages on topic successfully", result); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to expire messages on topic", e); + return createErrorResult("Failed to expire messages on topic: " + e.getMessage()); + } + }).build()); + } + + private void registerPeekTopicMessages(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "peek-topic-messages", + "Peek messages from a subscription of a topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription" + }, + "count": { + "type": "integer", + "description": "Number of messages to peek", + "minimum": 1 + } + }, + "required": ["topic", "subscription", "count"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + Integer count = getIntParam(request.arguments(), "count", 1); + + if (count == null || count <= 0) { + return createErrorResult("count must be >= 1"); + } + + var raw = pulsarAdmin.topics().peekMessages(topic, subscription, count); + List> messages = new ArrayList<>(); + if (raw != null) { + for (var msg : raw) { + Map m = new HashMap<>(); + try { + m.put("messageId", String.valueOf(msg.getMessageId())); + m.put("publishTime", msg.getPublishTime()); + m.put("eventTime", msg.getEventTime()); + m.put("key", msg.getKey()); + m.put("properties", msg.getProperties()); + byte[] payload = msg.getData(); + m.put("payloadBase64", payload == null + ? null : java.util.Base64.getEncoder().encodeToString(payload)); + } catch (Throwable t) { + m.put("error", "Failed to materialize message: " + t.getMessage()); + } + messages.add(m); + } + } + + Map results = new HashMap<>(); + results.put("topic", topic); + results.put("subscription", subscription); + results.put("count", count); + results.put("messages", messages); + + addTopicBreakdown(results, topic); + + return createSuccessResult("Messages peeked successfully", results); + + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Error peeking topic messages", e); + return createErrorResult("Failed to peek messages: " + e.getMessage()); + } + }).build()); + } + + private void registerResetTopicCursor(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "reset-topic-cursor", + "Reset the subscription cursor to a specific timestamp", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + }, + "subscription": { + "type": "string", + "description": "The name of the subscription" + }, + "timestamp": { + "type": "integer", + "description": "The timestamp (in milliseconds) to reset the cursor to", + "default": 0 + } + }, + "required": ["topic", "subscription"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + String subscription = getRequiredStringParam(request.arguments(), "subscription"); + Long timestamp = getLongParam(request.arguments(), "timestamp", 0L); + + if (timestamp == null) { + timestamp = 0L; + } + + if (timestamp <= 0L){ + pulsarAdmin.topics().resetCursor(topic, subscription, 0L); + } else { + pulsarAdmin.topics().resetCursor(topic, subscription, timestamp); + } + + Map response = new HashMap<>(); + response.put("topic", topic); + response.put("subscription", subscription); + response.put("timestamp", timestamp); + response.put("reset", true); + + addTopicBreakdown(response, topic); + + return createSuccessResult("Cursor reset successfully", response); + } catch (IllegalArgumentException e) { + return createErrorResult(e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to reset topic cursor", e); + return createErrorResult("Failed to reset topic cursor: " + e.getMessage()); + } + }).build()); + } + + private void registerGetTopicInternalStats(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-internal-stats", + "Get internal stats of a Pulsar topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + var internalStats = pulsarAdmin.topics().getInternalStats(topic); + + Map result = new HashMap<>(); + result.put("topic", topic); + result.put("entriesAddedCounter", internalStats.entriesAddedCounter); + result.put("numberOfEntries", internalStats.numberOfEntries); + result.put("totalSize", internalStats.totalSize); + result.put("currentLedgerEntries", internalStats.currentLedgerEntries); + result.put("currentLedgerSize", internalStats.currentLedgerSize); + result.put("lastLedgerCreatedTimestamp", internalStats.lastLedgerCreatedTimestamp); + result.put("lastLedgerCreationFailureTimestamp", + internalStats.lastLedgerCreationFailureTimestamp); + result.put("waitingCursorCount", internalStats.waitingCursorsCount); + result.put("pendingAddEntriesCount", internalStats.pendingAddEntriesCount); + + if (internalStats.ledgers != null && !internalStats.ledgers.isEmpty()) { + result.put("ledgers", internalStats.ledgers); + result.put("ledgerCount", internalStats.ledgers.size()); + } + + if (internalStats.cursors != null && !internalStats.cursors.isEmpty()) { + result.put("cursorCount", internalStats.cursors.size()); + Map cursors = new HashMap<>(); + internalStats.cursors.forEach((name, cursor) -> { + Map cursorInfo = new HashMap<>(); + cursorInfo.put("markDeletePosition", cursor.markDeletePosition); + cursorInfo.put("readPosition", cursor.readPosition); + cursorInfo.put("waitingReadOp", cursor.waitingReadOp); + cursorInfo.put("pendingReadOps", cursor.pendingReadOps); + cursorInfo.put("messagesConsumedCounter", + cursor.messagesConsumedCounter); + cursorInfo.put("cursorLedger", cursor.cursorLedger); + cursors.put(name, cursorInfo); + }); + result.put("cursors", cursors); + } + addTopicBreakdown(result, topic); + return createSuccessResult("Internal stats retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid input parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get internal stats", e); + return createErrorResult("Failed to get internal stats: " + e.getMessage()); + } + }) + .build()); + } + + private void registerGetPartitionedMetadata(McpSyncServer mcpServer) { + McpSchema.Tool tool = createTool( + "get-partitioned-metadata", + "Get partitioned metadata of a Pulsar topic", + """ + { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic name (simple: 'orders' or full: 'persistent://public/default/orders')" + } + }, + "required": ["topic"] + } + """ + ); + + mcpServer.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> { + try { + String topic = buildFullTopicName(request.arguments()); + + var partitionedMetadata = pulsarAdmin.topics().getPartitionedTopicMetadata(topic); + + Map result = new HashMap<>(); + result.put("topic", topic); + + int partitions = (partitionedMetadata == null) ? 0 : partitionedMetadata.partitions; + result.put("partitions", partitions); + result.put("isPartitioned", partitions > 0); + + if (partitions > 0) { + double msgRateIn = 0.0, msgRateOut = 0.0, msgThroughputIn = 0.0, msgThroughputOut = 0.0; + long storageSize = 0L; + Map partitionInfo = new HashMap<>(); + + for (int i = 0; i < partitions; i++) { + String p = topic + "-partition-" + i; + try { + TopicStats s = pulsarAdmin.topics().getStats(p); + if (s == null) { + continue; + } + msgRateIn += s.getMsgRateIn(); + msgRateOut += s.getMsgRateOut(); + msgThroughputIn += s.getMsgThroughputIn(); + msgThroughputOut += s.getMsgThroughputOut(); + storageSize += s.getStorageSize(); + + Map partStats = new HashMap<>(); + partStats.put("msgRateIn", s.getMsgRateIn()); + partStats.put("msgRateOut", s.getMsgRateOut()); + partStats.put("storageSize", s.getStorageSize()); + int subCount = (s.getSubscriptions() == null) + ? 0 : s.getSubscriptions().size(); + partStats.put("subscriptionCount", subCount); + partitionInfo.put(p, partStats); + } catch (Exception ex) { + Map err = new HashMap<>(); + err.put("error", "Failed to get stats: " + ex.getMessage()); + partitionInfo.put(p, err); + } + } + + result.put("msgRateIn", msgRateIn); + result.put("msgRateOut", msgRateOut); + result.put("msgThroughputIn", msgThroughputIn); + result.put("msgThroughputOut", msgThroughputOut); + result.put("storageSize", storageSize); + result.put("partitionStats", partitionInfo); + } else { + result.put("message", "Topic is not partitioned"); + } + + addTopicBreakdown(result, topic); + return createSuccessResult("Partitioned metadata retrieved successfully", result); + + } catch (IllegalArgumentException e) { + return createErrorResult("Invalid input parameter: " + e.getMessage()); + } catch (Exception e) { + LOGGER.error("Failed to get partitioned metadata", e); + return createErrorResult("Failed to get partitioned metadata: " + e.getMessage()); + } + }) + .build()); + } + +} + diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/package-info.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/package-info.java new file mode 100644 index 0000000..40d4c58 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/tools/package-info.java @@ -0,0 +1,14 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/AbstractMCPServer.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/AbstractMCPServer.java new file mode 100644 index 0000000..f3cfd7b --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/AbstractMCPServer.java @@ -0,0 +1,228 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import io.modelcontextprotocol.server.McpSyncServer; +import java.util.Set; +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.admin.mcp.tools.ClusterTools; +import org.apache.pulsar.admin.mcp.tools.MessageTools; +import org.apache.pulsar.admin.mcp.tools.MonitoringTools; +import org.apache.pulsar.admin.mcp.tools.NamespaceTools; +import org.apache.pulsar.admin.mcp.tools.SchemaTools; +import org.apache.pulsar.admin.mcp.tools.SubscriptionTools; +import org.apache.pulsar.admin.mcp.tools.TenantTools; +import org.apache.pulsar.admin.mcp.tools.TopicTools; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.PulsarClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractMCPServer { + + protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractMCPServer.class); + + protected PulsarClientManager pulsarClientManager; + protected static PulsarAdmin pulsarAdmin; + protected static PulsarClient pulsarClient; + + public void injectClientManager(PulsarClientManager manager) { + this.pulsarClientManager = manager; + } + + public void initializePulsar() { + if (this.pulsarClientManager == null) { + this.pulsarClientManager = new PulsarClientManager(); + } + this.pulsarClientManager.initialize(); + + pulsarAdmin = pulsarClientManager.getAdmin(); + pulsarClient = pulsarClientManager.getClient(); + } + + protected void registerAllTools(McpSyncServer mcpServer) { + try { + registerToolsConditionally(mcpServer, getAllAvailableTools(), pulsarClientManager); + } catch (Exception e) { + throw new RuntimeException("Failed to register tools", e); + } + } + + protected static void registerToolsConditionally( + McpSyncServer mcpServer, Set enabledTools, + PulsarClientManager pulsarClientManager) { + if (pulsarAdmin == null) { + throw new RuntimeException("PulsarAdmin has not been initialized"); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("cluster") || tool.contains("broker"))) { + registerToolGroup("ClusterTools", () -> { + var clusterTools = new ClusterTools(pulsarAdmin); + clusterTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("topic"))) { + registerToolGroup("TopicTools", () -> { + var topicTools = new TopicTools(pulsarAdmin); + topicTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("tenant"))) { + registerToolGroup("TenantTools", () -> { + var tenantTools = new TenantTools(pulsarAdmin); + tenantTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("namespace") + || tool.contains("retention") || tool.contains("backlog"))) { + registerToolGroup("NamespaceTools", () -> { + var namespaceTools = new NamespaceTools(pulsarAdmin); + namespaceTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("schema"))) { + registerToolGroup("SchemaTools", () -> { + var schemaTools = new SchemaTools(pulsarAdmin); + schemaTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("message"))) { + registerToolGroup("MessageTools", () -> { + var messageTools = new MessageTools(pulsarAdmin, pulsarClientManager); + messageTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("subscription") || tool.contains("unsubscribe"))) { + registerToolGroup("SubscriptionTools", () -> { + var subscriptionTools = new SubscriptionTools(pulsarAdmin); + subscriptionTools.registerTools(mcpServer); + }); + } + + if (enabledTools.stream().anyMatch(tool -> tool.contains("monitor") + || tool.contains("health") || tool.contains("backlog-analysis"))) { + registerToolGroup("MonitoringTools", () -> { + var monitoringTools = new MonitoringTools(pulsarAdmin); + monitoringTools.registerTools(mcpServer); + }); + } + } + + private static void registerToolGroup(String toolGroupName, Runnable registrationTask) { + try { + registrationTask.run(); + } catch (NoClassDefFoundError e) { + LOGGER.error("{} dependencies missing: {}", toolGroupName, e.getMessage()); + } catch (Exception e) { + if (e.getCause() instanceof ClassNotFoundException) { + LOGGER.error("{} not available in this configuration (class not found)", toolGroupName); + } else { + LOGGER.error("{} dependencies missing: {}", toolGroupName, e.getMessage()); + if (Boolean.parseBoolean(System.getProperty("mcp.debug", "false"))) { + LOGGER.debug("Exception details", e); + } + } + } + } + + protected static Set getAllAvailableTools() { + return Set.of( + "list-clusters", + "get-cluster-info", + "create-cluster", + "update-cluster-config", + "delete-cluster", + "get-cluster-stats", + "list-brokers", + "get-broker-stats", + "get-cluster-failure-domain", + "set-cluster-failure-domain", + + "list-tenants", + "get-tenant-info", + "create-tenant", + "update-tenant", + "delete-tenant", + "get-tenant-stats", + + "list-namespaces", + "get-namespace-info", + "create-namespace", + "delete-namespace", + "set-retention-policy", + "get-retention-policy", + "set-backlog-quota", + "get-backlog-quota", + "clear-namespace-backlog", + "get-namespace-stats", + + "list-topics", + "create-topic", + "delete-topic", + "get-topic-stats", + "get-topic-metadata", + "update-topic-partitions", + "compact-topic", + "unload-topic", + "get-topic-backlog", + "expire-topic-messages", + "peek-messages", + "reset-topic-cursor", + "get-topic-internal-stats", + "get-partitioned-metadata", + + "list-subscriptions", + "create-subscription", + "delete-subscription", + "get-subscription-stats", + "reset-subscription-cursor", + "skip-messages", + "expire-subscription-messages", + "unsubscribe", + "list-subscription-consumers", + "get-subscription-cursor-positions", + + "send-message", + "peek-message", + "examine-messages", + "get-message-by-id", + "get-message-backlog", + "get-message-stats", + "receive-messages", + "skip-all-messages", + "expire-all-messages", + + "get-schema-info", + "get-schema-version", + "get-all-schema-versions", + "upload-schema", + "delete-schema", + "test-schema-compatibility", + + "monitor-cluster-performance", + "monitor-topic-performance", + "monitor-subscription-performance", + "health-check", + "connection-diagnostics", + "backlog-analysis" + ); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServer.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServer.java new file mode 100644 index 0000000..30ffa2a --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServer.java @@ -0,0 +1,158 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pulsar.admin.mcp.transport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class HttpMCPServer extends AbstractMCPServer implements Transport { + + private static final Logger logger = LoggerFactory.getLogger(HttpMCPServer.class); + + private final AtomicBoolean running = new AtomicBoolean(false); + private Server jettyServer; + + public HttpMCPServer() { + super(); + } + + @Override + public void start(PulsarMCPCliOptions options) throws Exception { + if (!running.compareAndSet(false, true)) { + logger.warn("Server is already running"); + return; + } + try { + if (this.pulsarClientManager == null) { + running.set(false); + throw new IllegalStateException("PulsarClientManager not injected."); + } + try { + pulsarAdmin = pulsarClientManager.getAdmin(); + pulsarClient = pulsarClientManager.getClient(); + } catch (Exception e) { + running.set(false); + throw new RuntimeException("Failed to obtain PulsarAdmin from PulsarClientManager", e); + } + logger.info("Starting HTTP Streaming Pulsar MCP server"); + + ObjectMapper mapper = new ObjectMapper() + .findAndRegisterModules() + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + + var streamingTransport = HttpServletStreamableServerTransportProvider + .builder() + .objectMapper(mapper) + .build(); + + var mcpServer = McpServer.sync(streamingTransport) + .serverInfo("pulsar-admin-http-streaming", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(true) + .build()) + .build(); + + registerAllTools(mcpServer); + startJettyServer(streamingTransport, options.getHttpPort()); + + logger.info("HTTP Streaming Pulsar MCP server started " + + "at http://localhost:{}/mcp", options.getHttpPort()); + + } catch (Exception e) { + running.set(false); + logger.error("Failed to start HTTP streaming server", e); + throw e; + } + } + + @Override + public void stop() { + if (!running.compareAndSet(true, false)) { + return; + } + logger.info("Stopping HTTP Streaming Pulsar MCP server...."); + if (jettyServer != null) { + try { + if (jettyServer.isRunning()) { + jettyServer.stop(); + } + } catch (Exception e) { + logger.warn("Error stopping Jetty: {}", e.getMessage()); + } + } + if (pulsarClientManager != null) { + try { + pulsarClientManager.close(); + } catch (Exception e) { + logger.warn("Error closing PulsarClientManager: {}", e.getMessage()); + } + } + logger.info("HTTP Streaming Pulsar MCP server stopped"); + } + + @Override + public PulsarMCPCliOptions.TransportType getType() { + return PulsarMCPCliOptions.TransportType.HTTP; + } + + private void startJettyServer(HttpServletStreamableServerTransportProvider + streamingTransport, int httpPort) throws Exception { + jettyServer = new Server(httpPort); + + var context = new ServletContextHandler(); + context.setContextPath("/"); + jettyServer.setHandler(context); + + ServletHolder servletHolder = new ServletHolder(streamingTransport); + servletHolder.setAsyncSupported(true); + + context.addServlet(servletHolder, "/mcp"); + context.addServlet(servletHolder, "/mcp/*"); + context.addServlet(servletHolder, "/mcp/stream"); + context.addServlet(servletHolder, "/mcp/stream/*"); + + jettyServer.start(); + } + + public static void main(String[] args) { + try { + HttpMCPServer transport = new HttpMCPServer(); + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + PulsarClientManager manager = new PulsarClientManager(); + manager.initialize(); + transport.injectClientManager(manager); + + transport.start(options); + + Thread.currentThread().join(); + + } catch (Exception e) { + logger.error("Error starting HTTP Streaming Pulsar MCP server: {}", e.getMessage(), e); + System.exit(1); + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServer.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServer.java new file mode 100644 index 0000000..b039114 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServer.java @@ -0,0 +1,99 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StdioMCPServer extends AbstractMCPServer implements Transport { + + private static final Logger logger = LoggerFactory.getLogger(StdioMCPServer.class); + private final AtomicBoolean running = new AtomicBoolean(false); + + public StdioMCPServer() { + super(); + } + + @Override + public void start(PulsarMCPCliOptions options) { + if (!running.compareAndSet(false, true)) { + logger.warn("Stdio transport is already running"); + return; + } + + if (this.pulsarClientManager == null) { + running.set(false); + throw new IllegalStateException("PulsarClientManager not injected."); + } + + try { + initializePulsar(); + } catch (Exception e) { + running.set(false); + logger.error("Failed to initialize PulsarAdmin", e); + throw new RuntimeException("Cannot start MCP server without Pulsar connection. " + + "Please ensure Pulsar is running at" + + System.getProperty("PULSAR_ADMIN_URL", "http://localhost:8080"), e); + } + + var mcpServer = McpServer.sync(new StdioServerTransportProvider()) + .serverInfo("pulsar-admin-stdio", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + + registerAllTools(mcpServer); + + } + + + @Override + public void stop() { + if (!running.get()) { + return; + } + + running.set(false); + + if (pulsarClientManager != null) { + try { + pulsarClientManager.close(); + } catch (Exception e) { + logger.warn("Error closing PulsarManager: {}", e.getMessage()); + } + } + + logger.info("Pulsar MCP server stopped successfully"); + } + + @Override + public PulsarMCPCliOptions.TransportType getType() { + return PulsarMCPCliOptions.TransportType.STDIO; + } + + public static void main(String[] args) { + try { + StdioMCPServer server = new StdioMCPServer(); + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + server.start(options); + } catch (Exception e) { + logger.error("Error starting Pulsar MCP server: {}", e.getMessage(), e); + System.exit(1); + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/Transport.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/Transport.java new file mode 100644 index 0000000..674f337 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/Transport.java @@ -0,0 +1,29 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; + +public interface Transport { + + void start(PulsarMCPCliOptions options) throws Exception; + + void stop() throws Exception; + + PulsarMCPCliOptions.TransportType getType(); + + default String getDescription(){ + return getType().getDescription(); + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportLauncher.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportLauncher.java new file mode 100644 index 0000000..4c130c6 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportLauncher.java @@ -0,0 +1,70 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pulsar.admin.mcp.transport; + +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; + +public class TransportLauncher { + + public static void start(PulsarMCPCliOptions options) throws Exception { + startTransport(options); + } + + private static void startTransport(PulsarMCPCliOptions options) throws Exception { + TransportManager transportManager = new TransportManager(); + + StdioMCPServer stdio = new StdioMCPServer(); + HttpMCPServer http = new HttpMCPServer(); + + PulsarClientManager manager = new PulsarClientManager(); + manager.initialize(); + stdio.injectClientManager(manager); + http.injectClientManager(manager); + + transportManager.registerTransport(stdio); + transportManager.registerTransport(http); + + final PulsarMCPCliOptions.TransportType chosen = options.getTransport(); + final Transport[] started = new Transport[1]; + + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (started[0] != null) { + try { + started[0].stop(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + try { + manager.close(); + } catch (Exception ignore) { + + } + }, "pulsar-manager-shutdown")); + + switch (chosen) { + case HTTP -> { + transportManager.startTransport(PulsarMCPCliOptions.TransportType.HTTP, options); + started[0] = http; + } + case STDIO -> { + transportManager.startTransport(PulsarMCPCliOptions.TransportType.STDIO, options); + started[0] = stdio; + } + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportManager.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportManager.java new file mode 100644 index 0000000..8c7eb43 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/TransportManager.java @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions.TransportType; + +public class TransportManager { + + private final Map transports = new ConcurrentHashMap<>(); + + public void registerTransport(Transport transport) { + TransportType type = transport.getType(); + transports.put(type, transport); + } + + public void startTransport(TransportType type, PulsarMCPCliOptions options) throws Exception { + Transport transport = transports.get(type); + if (transport == null) { + throw new IllegalArgumentException("Transport not registered: " + type); + } + transport.start(options); + } + +} diff --git a/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/package-info.java b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/package-info.java new file mode 100644 index 0000000..c59d263 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/java/org/apache/pulsar/admin/mcp/transport/package-info.java @@ -0,0 +1,14 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; diff --git a/pulsar-admin-mcp-contrib/src/main/resources/application.yml b/pulsar-admin-mcp-contrib/src/main/resources/application.yml new file mode 100644 index 0000000..da22574 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/resources/application.yml @@ -0,0 +1,114 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring: + ai: + mcp: + server: + name: pulsar-admin-mcp-server + version: 1.0.0 + type: ASYNC + instructions: "Apache pulsar Admin Mcp Server providing comprehensive pulsar cluster management operations through natural language interface" + + # Transport configuration + sse-message-endpoint: "/mcp/message" + sse-endpoint: "/sse" + + # Capabilities + capabilities: + resource: true + tool: true + prompt: true + completion: true + + # Change notifications + resource-change-notification: true + prompt-change-notification: true + tool-change-notification: true + +server: + port: 8889 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + +# Pulsar connection configuration +pulsar: + admin: + url: "http://localhost:8080" + service: + url: "pulsar://localhost:6650" + +# Mcp feature flags +mcp: + features: + pulsar-admin-tenants: true + pulsar-admin-functions: true + pulsar-admin-sources: true + pulsar-admin-sinks: true + pulsar-admin-schemas: true + pulsar-admin-packages: true + pulsar-admin-subscriptions: true + + pulsar-client: true + + functions-as-tools: false + context-management: false + parameter-validation: true + error-handling: true + +# Logging configuration +logging: + level: + root: OFF + org.apache.pulsar.admin.mcp: OFF + org.springframework: OFF + org.apache.pulsar: OFF + pattern: + console: "" + +# Security profiles +--- +spring: + config: + activate: + on-profile: write + +mcp: + security: + read-only: false + allowed-operations: + - CREATE + - UPDATE + - DELETE + +--- +spring: + config: + activate: + on-profile: read-only + +mcp: + security: + read-only: true + allowed-operations: + - LIST + - GET + - STATS diff --git a/pulsar-admin-mcp-contrib/src/main/resources/logback-spring.xml b/pulsar-admin-mcp-contrib/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..d3278ce --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/main/resources/logback-spring.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/client/PulsarClientManagerTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/client/PulsarClientManagerTest.java new file mode 100644 index 0000000..422db5f --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/client/PulsarClientManagerTest.java @@ -0,0 +1,185 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.client; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class PulsarClientManagerTest { + + private PulsarClientManager manager; + private String originalAdminUrl; + private String originalServiceUrl; + + @BeforeMethod + public void setUp() { + manager = new PulsarClientManager(); + + originalAdminUrl = System.getenv("PULSAR_ADMIN_URL"); + originalServiceUrl = System.getenv("PULSAR_SERVICE_URL"); + + System.clearProperty("PULSAR_ADMIN_URL"); + System.clearProperty("PULSAR_SERVICE_URL"); + } + + @AfterMethod + public void tearDown() { + if (manager != null) { + try { + manager.close(); + } catch (Exception ignore) { + } + } + if (originalAdminUrl != null) { + System.setProperty("PULSAR_ADMIN_URL", originalAdminUrl); + } else { + System.clearProperty("PULSAR_ADMIN_URL"); + } + + if (originalServiceUrl != null) { + System.setProperty("PULSAR_SERVICE_URL", originalServiceUrl); + } else { + System.clearProperty("PULSAR_SERVICE_URL"); + } + } + + @Test( + expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = ".*Failed to initialize PulsarAdmin.*" + ) + public void initialize_shouldThrowException_whenPulsarNotRunning() { + System.setProperty("PULSAR_ADMIN_URL", "http://localhost:99999"); + System.setProperty("PULSAR_SERVICE_URL", "pulsar://localhost:99999"); + + manager.initialize(); + } + + @Test + public void getClient_shouldNotThrowException_whenPulsarNotRunning() { + System.setProperty("PULSAR_ADMIN_URL", "http://localhost:8080"); + System.setProperty("PULSAR_SERVICE_URL", "pulsar://localhost:99999"); + + try { + org.apache.pulsar.client.api.PulsarClient client = manager.getClient(); + org.testng.Assert.assertNotNull(client); + } catch (Exception e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize PulsarClient")); + } + } + + @Test + public void close_shouldCloseBothAdminAndClient() throws Exception { + PulsarClientManager testManager = new PulsarClientManager(); + + testManager.close(); + + testManager.close(); + } + + @Test + public void initialize_shouldSetDefaultUrls() { + try { + manager.initialize(); + org.testng.Assert.fail("Expected exception for missing Pulsar connection"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue( + e.getMessage().contains("Failed to initialize PulsarAdmin") + ); + } + } + + @Test + public void getAdmin_shouldUseDefaultUrl() { + try { + manager.getAdmin(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize PulsarAdmin")); + } + } + + @Test + public void getAdmin_shouldUseEnvVarWhenSet() { + System.setProperty("PULSAR_ADMIN_URL", "http://localhost:8080"); + + try { + manager.getAdmin(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize PulsarAdmin")); + } + } + + @Test + public void getClient_shouldUseDefaultUrl() { + org.apache.pulsar.client.api.PulsarClient client = manager.getClient(); + org.testng.Assert.assertNotNull(client); + } + + @Test + public void getClient_shouldUseEnvVarWhenSet() { + System.setProperty("PULSAR_SERVICE_URL", "pulsar://localhost:6650"); + + org.apache.pulsar.client.api.PulsarClient client = manager.getClient(); + org.testng.Assert.assertNotNull(client); + } + + @Test + public void initialize_shouldCallGetAdminAndGetClient() { + try { + manager.initialize(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertNotNull(e.getMessage()); + } + } + + @Test + public void close_shouldBeIdempotent() throws Exception { + manager.close(); + manager.close(); + manager.close(); + } + + @Test + public void initialize_shouldHandleInvalidUrl() { + System.setProperty("PULSAR_ADMIN_URL", "invalid-url"); + System.setProperty("PULSAR_SERVICE_URL", "invalid-url"); + + try { + manager.initialize(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize")); + } + } + + @Test + public void singletonBehavior_shouldReturnSameInstance() { + try { + manager.getAdmin(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize PulsarAdmin")); + } + + try { + manager.getAdmin(); + org.testng.Assert.fail("Expected exception"); + } catch (RuntimeException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Failed to initialize PulsarAdmin")); + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptionsTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptionsTest.java new file mode 100644 index 0000000..72c16fe --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/config/PulsarMCPCliOptionsTest.java @@ -0,0 +1,192 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.config; + +import org.testng.annotations.Test; + +public class PulsarMCPCliOptionsTest { + + @Test + public void parseArgs_shouldParseDefaultOptions() { + String[] args = {}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.STDIO); + org.testng.Assert.assertEquals(options.getHttpPort(), 8889); + } + + @Test + public void parseArgs_shouldParseTransportOption() { + String[] args = {"--transport", "http"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.HTTP); + org.testng.Assert.assertEquals(options.getHttpPort(), 8889); + } + + @Test + public void parseArgs_shouldParseTransportOptionWithShortForm() { + String[] args = {"-t", "stdio"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.STDIO); + org.testng.Assert.assertEquals(options.getHttpPort(), 8889); + } + + @Test + public void parseArgs_shouldParsePortOption() { + String[] args = {"--port", "9999"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.STDIO); + org.testng.Assert.assertEquals(options.getHttpPort(), 9999); + } + + @Test + public void parseArgs_shouldParseBothOptions() { + String[] args = {"--transport", "http", "--port", "8080"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.HTTP); + org.testng.Assert.assertEquals(options.getHttpPort(), 8080); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Missing value for --transport" + ) + public void parseArgs_shouldThrowException_whenTransportValueMissing() { + PulsarMCPCliOptions.parseArgs(new String[]{"--transport"}); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Missing value for --port" + ) + public void parseArgs_shouldThrowException_whenPortValueMissing() { + PulsarMCPCliOptions.parseArgs(new String[]{"--port"}); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = ".*invalid is not a valid TransportType.*Valid Options: stdio,http.*" + ) + public void parseArgs_shouldThrowException_whenInvalidTransport() { + PulsarMCPCliOptions.parseArgs(new String[]{"--transport", "invalid"}); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Invalid port number for --port" + ) + public void parseArgs_shouldThrowException_whenInvalidPort() { + PulsarMCPCliOptions.parseArgs(new String[]{"--port", "invalid"}); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Unknown argument: --unknown" + ) + public void parseArgs_shouldThrowException_whenUnknownArgument() { + PulsarMCPCliOptions.parseArgs(new String[]{"--unknown", "value"}); + } + + @Test + public void parseArgs_shouldHandleCaseInsensitiveTransport() { + String[] args = {"--transport", "HTTP"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.HTTP); + } + + @Test + public void parseArgs_shouldHandleMixedCaseTransport() { + String[] args = {"--transport", "Stdio"}; + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(args); + + org.testng.Assert.assertEquals(options.getTransport(), PulsarMCPCliOptions.TransportType.STDIO); + } + + @Test + public void toString_shouldReturnCorrectFormat() throws Exception { + PulsarMCPCliOptions options = new PulsarMCPCliOptions(); + + java.lang.reflect.Field transportField = PulsarMCPCliOptions.class.getDeclaredField("transport"); + transportField.setAccessible(true); + transportField.set(options, PulsarMCPCliOptions.TransportType.HTTP); + + java.lang.reflect.Field portField = PulsarMCPCliOptions.class.getDeclaredField("httpPort"); + portField.setAccessible(true); + portField.set(options, 9999); + + String result = options.toString(); + org.testng.Assert.assertTrue(result.contains("transport=HTTP")); + org.testng.Assert.assertTrue(result.contains("httpPort=9999")); + } + + @Test + public void transportType_fromString_shouldReturnCorrectType() { + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.fromString("stdio"), + PulsarMCPCliOptions.TransportType.STDIO + ); + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.fromString("http"), + PulsarMCPCliOptions.TransportType.HTTP + ); + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.fromString("STDIO"), + PulsarMCPCliOptions.TransportType.STDIO + ); + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.fromString("HTTP"), + PulsarMCPCliOptions.TransportType.HTTP + ); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = ".*invalid is not a valid TransportType.*Valid Options: stdio,http.*" + ) + public void transportType_fromString_shouldThrowException_whenInvalidType() { + PulsarMCPCliOptions.TransportType.fromString("invalid"); + } + + @Test + public void transportType_values_shouldHaveCorrectValues() { + PulsarMCPCliOptions.TransportType[] values = PulsarMCPCliOptions.TransportType.values(); + + org.testng.Assert.assertEquals(values.length, 2); + org.testng.Assert.assertEquals(values[0], PulsarMCPCliOptions.TransportType.STDIO); + org.testng.Assert.assertEquals(values[1], PulsarMCPCliOptions.TransportType.HTTP); + } + + @Test + public void transportType_getValue_shouldReturnCorrectValue() { + org.testng.Assert.assertEquals(PulsarMCPCliOptions.TransportType.STDIO.getValue(), "stdio"); + org.testng.Assert.assertEquals(PulsarMCPCliOptions.TransportType.HTTP.getValue(), "http"); + } + + @Test + public void transportType_getDescription_shouldReturnCorrectDescription() { + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.STDIO.getDescription(), + "Standard input/output (Claude Desktop)" + ); + org.testng.Assert.assertEquals( + PulsarMCPCliOptions.TransportType.HTTP.getDescription(), + "HTTP Streaming Events (Web application)" + ); + } +} diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/tools/BasePulsarToolsTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/tools/BasePulsarToolsTest.java new file mode 100644 index 0000000..1a7b05f --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/tools/BasePulsarToolsTest.java @@ -0,0 +1,403 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.tools; + +import io.modelcontextprotocol.spec.McpSchema; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class BasePulsarToolsTest { + + @Mock + private PulsarAdmin mockPulsarAdmin; + + private BasePulsarToolsTest.TestPulsarTools testTools; + + private AutoCloseable mocks; + + @BeforeMethod + public void setUp() { + this.mocks = MockitoAnnotations.openMocks(this); + this.testTools = new BasePulsarToolsTest.TestPulsarTools(mockPulsarAdmin); + } + + @AfterMethod + public void tearDown() throws Exception { + if (this.mocks != null) { + this.mocks.close(); + } + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "pulsarAdmin cannot be null" + ) + public void constructor_shouldThrowException_whenPulsarAdminIsNull() { + new BasePulsarToolsTest.TestPulsarTools(null); + } + + @Test + public void createSuccessResult_shouldCreateSuccessResultWithData() { + Map data = new HashMap<>(); + data.put("key", "value"); + data.put("count", 42); + + McpSchema.CallToolResult result = testTools.createSuccessResult("Test message", data); + + org.testng.Assert.assertFalse(result.isError()); + org.testng.Assert.assertEquals(result.content().size(), 1); + org.testng.Assert.assertTrue(result.content().get(0) instanceof McpSchema.TextContent); + String content = ((McpSchema.TextContent) result.content().get(0)).text(); + org.testng.Assert.assertTrue(content.contains("Test message")); + org.testng.Assert.assertTrue(content.contains("key")); + org.testng.Assert.assertTrue(content.contains("value")); + } + + @Test + public void createSuccessResult_shouldCreateSuccessResultWithoutData() { + McpSchema.CallToolResult result = testTools.createSuccessResult("Test message", null); + + org.testng.Assert.assertFalse(result.isError()); + org.testng.Assert.assertEquals(result.content().size(), 1); + org.testng.Assert.assertTrue(result.content().get(0) instanceof McpSchema.TextContent); + String content = ((McpSchema.TextContent) result.content().get(0)).text(); + org.testng.Assert.assertEquals(content, "Test message\n"); + } + + @Test + public void createErrorResult_shouldCreateErrorResultWithMessage() { + McpSchema.CallToolResult result = testTools.createErrorResult("Test error"); + + org.testng.Assert.assertTrue(result.isError()); + org.testng.Assert.assertEquals(result.content().size(), 1); + org.testng.Assert.assertTrue(result.content().get(0) instanceof McpSchema.TextContent); + String content = ((McpSchema.TextContent) result.content().get(0)).text(); + org.testng.Assert.assertEquals(content, "Error: Test error"); + } + + @Test + public void createErrorResult_shouldCreateErrorResultWithSuggestions() { + List suggestions = List.of("Suggestion 1", "Suggestion 2"); + McpSchema.CallToolResult result = testTools.createErrorResult("Test error", suggestions); + + org.testng.Assert.assertTrue(result.isError()); + org.testng.Assert.assertEquals(result.content().size(), 1); + org.testng.Assert.assertTrue(result.content().get(0) instanceof McpSchema.TextContent); + String content = ((McpSchema.TextContent) result.content().get(0)).text(); + org.testng.Assert.assertTrue(content.contains("Test error")); + org.testng.Assert.assertTrue(content.contains("Suggestion 1")); + org.testng.Assert.assertTrue(content.contains("Suggestion 2")); + } + + @Test + public void getStringParam_shouldReturnValue_whenKeyExists() { + Map params = new HashMap<>(); + params.put("testKey", "testValue"); + params.put("nullKey", null); + + org.testng.Assert.assertEquals(testTools.getStringParam(params, "testKey"), "testValue"); + org.testng.Assert.assertEquals(testTools.getStringParam(params, "nullKey"), ""); + org.testng.Assert.assertEquals(testTools.getStringParam(params, "nonExistentKey"), ""); + } + + @Test + public void getRequiredStringParam_shouldReturnValue_whenKeyExistsAndNotEmpty() { + Map params = new HashMap<>(); + params.put("testKey", "testValue"); + params.put("emptyKey", " "); + params.put("nullKey", null); + + org.testng.Assert.assertEquals(testTools.getRequiredStringParam(params, "testKey"), "testValue"); + + // emptyKey + try { + testTools.getRequiredStringParam(params, "emptyKey"); + org.testng.Assert.fail("Expected IllegalArgumentException for emptyKey"); + } catch (IllegalArgumentException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Required parameter 'emptyKey' is missing")); + } + + // nullKey + try { + testTools.getRequiredStringParam(params, "nullKey"); + org.testng.Assert.fail("Expected IllegalArgumentException for nullKey"); + } catch (IllegalArgumentException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Required parameter 'nullKey' is missing")); + } + + // nonExistentKey + try { + testTools.getRequiredStringParam(params, "nonExistentKey"); + org.testng.Assert.fail("Expected IllegalArgumentException for nonExistentKey"); + } catch (IllegalArgumentException e) { + org.testng.Assert.assertTrue(e.getMessage().contains("Required parameter 'nonExistentKey' is missing")); + } + } + + @Test + public void getIntParam_shouldReturnValue_whenKeyExists() { + Map params = new HashMap<>(); + params.put("intKey", 42); + params.put("stringKey", "123"); + params.put("doubleKey", 45.6); + params.put("nullKey", null); + + org.testng.Assert.assertEquals(testTools.getIntParam(params, "intKey", 0), Integer.valueOf(42)); + org.testng.Assert.assertEquals(testTools.getIntParam(params, "stringKey", 0), Integer.valueOf(123)); + org.testng.Assert.assertEquals(testTools.getIntParam(params, "doubleKey", 0), Integer.valueOf(45)); + org.testng.Assert.assertEquals(testTools.getIntParam(params, "nullKey", 0), Integer.valueOf(0)); + org.testng.Assert.assertEquals(testTools.getIntParam(params, "nonExistentKey", 0), Integer.valueOf(0)); + } + + @Test + public void getIntParam_shouldReturnDefault_whenInvalidFormat() { + Map params = new HashMap<>(); + params.put("invalidKey", "not-a-number"); + + org.testng.Assert.assertEquals(testTools.getIntParam(params, "invalidKey", 0), Integer.valueOf(0)); + } + + @Test + public void getBooleanParam_shouldReturnValue_whenKeyExists() { + Map params = new HashMap<>(); + params.put("boolKey", true); + params.put("stringKey", "true"); + params.put("nullKey", null); + + org.testng.Assert.assertEquals(testTools.getBooleanParam(params, "boolKey", false), Boolean.TRUE); + org.testng.Assert.assertEquals(testTools.getBooleanParam(params, "stringKey", false), Boolean.TRUE); + org.testng.Assert.assertEquals(testTools.getBooleanParam(params, "nullKey", false), Boolean.FALSE); + org.testng.Assert.assertEquals(testTools.getBooleanParam(params, "nonExistentKey", false), Boolean.FALSE); + } + + @Test + public void getLongParam_shouldReturnValue_whenKeyExists() { + Map params = new HashMap<>(); + params.put("longKey", 123L); + params.put("intKey", 456); + params.put("stringKey", "789"); + params.put("nullKey", null); + + org.testng.Assert.assertEquals(testTools.getLongParam(params, "longKey", 0L), Long.valueOf(123L)); + org.testng.Assert.assertEquals(testTools.getLongParam(params, "intKey", 0L), Long.valueOf(456L)); + org.testng.Assert.assertEquals(testTools.getLongParam(params, "stringKey", 0L), Long.valueOf(789L)); + org.testng.Assert.assertEquals(testTools.getLongParam(params, "nullKey", 0L), Long.valueOf(0L)); + org.testng.Assert.assertEquals(testTools.getLongParam(params, "nonExistentKey", 0L), Long.valueOf(0L)); + } + + @Test + public void getLongParam_shouldReturnDefault_whenInvalidFormat() { + Map params = new HashMap<>(); + params.put("invalidKey", "not-a-number"); + + org.testng.Assert.assertEquals(testTools.getLongParam(params, "invalidKey", 0L), Long.valueOf(0L)); + } + + @Test + public void buildFullTopicName_shouldBuildPersistentTopic_whenTopicStartsWithPersistent() { + Map params = new HashMap<>(); + params.put("topic", "persistent://tenant/namespace/topic"); + + String result = testTools.buildFullTopicName(params); + org.testng.Assert.assertEquals(result, "persistent://tenant/namespace/topic"); + } + + @Test + public void buildFullTopicName_shouldBuildNonPersistentTopic_whenTopicStartsWithNonPersistent() { + Map params = new HashMap<>(); + params.put("topic", "non-persistent://tenant/namespace/topic"); + + String result = testTools.buildFullTopicName(params); + org.testng.Assert.assertEquals(result, "non-persistent://tenant/namespace/topic"); + } + + @Test + public void buildFullTopicName_shouldBuildTopicFromComponents() { + Map params = new HashMap<>(); + params.put("topic", "my-topic"); + params.put("tenant", "my-tenant"); + params.put("namespace", "my-namespace"); + params.put("persistent", true); + + String result = testTools.buildFullTopicName(params); + org.testng.Assert.assertEquals(result, "persistent://my-tenant/my-namespace/my-topic"); + } + + @Test + public void buildFullTopicName_shouldBuildNonPersistentTopicFromComponents() { + Map params = new HashMap<>(); + params.put("topic", "my-topic"); + params.put("tenant", "my-tenant"); + params.put("namespace", "my-namespace"); + params.put("persistent", false); + + String result = testTools.buildFullTopicName(params); + org.testng.Assert.assertEquals(result, "non-persistent://my-tenant/my-namespace/my-topic"); + } + + @Test + public void buildFullTopicName_shouldUseDefaultValues() { + Map params = new HashMap<>(); + params.put("topic", "my-topic"); + + String result = testTools.buildFullTopicName(params); + org.testng.Assert.assertEquals(result, "persistent://public/default/my-topic"); + } + + @Test + public void resolveNamespace_shouldReturnFullNamespace_whenContainsSlash() { + Map params = new HashMap<>(); + params.put("namespace", "tenant/namespace"); + + String result = testTools.resolveNamespace(params); + org.testng.Assert.assertEquals(result, "tenant/namespace"); + } + + @Test + public void resolveNamespace_shouldBuildFromTenantAndNamespace() { + Map params = new HashMap<>(); + params.put("tenant", "my-tenant"); + params.put("namespace", "my-namespace"); + + String result = testTools.resolveNamespace(params); + org.testng.Assert.assertEquals(result, "my-tenant/my-namespace"); + } + + @Test + public void resolveNamespace_shouldUseDefaultValues() { + Map params = new HashMap<>(); + String result = testTools.resolveNamespace(params); + org.testng.Assert.assertTrue( + "public/default".equals(result) || "/".equals(result), + "Unexpected default namespace: " + result + ); + } + + + @Test + public void addTopicBreakdown_shouldBreakDownPersistentTopic() { + Map result = new HashMap<>(); + testTools.addTopicBreakdown(result, "persistent://tenant/namespace/topic"); + + org.testng.Assert.assertEquals(result.get("tenant"), "tenant"); + org.testng.Assert.assertEquals(result.get("namespace"), "namespace"); + org.testng.Assert.assertEquals(result.get("topicName"), "topic"); + } + + @Test + public void addTopicBreakdown_shouldBreakDownNonPersistentTopic() { + Map result = new HashMap<>(); + testTools.addTopicBreakdown(result, "non-persistent://tenant/namespace/topic"); + + org.testng.Assert.assertEquals(result.get("tenant"), "tenant"); + org.testng.Assert.assertEquals(result.get("namespace"), "namespace"); + org.testng.Assert.assertEquals(result.get("topicName"), "topic"); + } + + @Test + public void addTopicBreakdown_shouldNotBreakDownInvalidTopic() { + Map result = new HashMap<>(); + testTools.addTopicBreakdown(result, "invalid-topic"); + + org.testng.Assert.assertTrue(result.isEmpty()); + } + + @Test + public void createTool_shouldCreateToolWithCorrectProperties() { + McpSchema.Tool tool = BasePulsarTools.createTool("test-tool", "Test description", "{}"); + + org.testng.Assert.assertEquals(tool.name(), "test-tool"); + org.testng.Assert.assertEquals(tool.description(), "Test description"); + Object schema = tool.inputSchema(); + if (schema != null) { + try { + java.lang.reflect.Field props = schema.getClass().getDeclaredField("properties"); + props.setAccessible(true); + Object val = props.get(schema); + org.testng.Assert.assertNull(val, "JsonSchema.properties should be null for empty schema"); + } catch (Exception ignore) { + } + } else { + org.testng.Assert.fail("Unexpected inputSchema type: " + "null"); + } + } + + private static class TestPulsarTools extends BasePulsarTools { + TestPulsarTools(PulsarAdmin pulsarAdmin) { + super(pulsarAdmin); + } + + @Override + public McpSchema.CallToolResult createSuccessResult(String message, Object data) { + return super.createSuccessResult(message, data); + } + + @Override + public McpSchema.CallToolResult createErrorResult(String message) { + return super.createErrorResult(message); + } + + @Override + public McpSchema.CallToolResult createErrorResult(String message, List suggestions) { + return super.createErrorResult(message, suggestions); + } + + @Override + public String getStringParam(Map map, String key) { + return super.getStringParam(map, key); + } + + @Override + public String getRequiredStringParam(Map map, String key) { + return super.getRequiredStringParam(map, key); + } + + @Override + public Integer getIntParam(Map map, String key, Integer defaultValue) { + return super.getIntParam(map, key, defaultValue); + } + + @Override + public Boolean getBooleanParam(Map map, String key, Boolean defaultValue) { + return super.getBooleanParam(map, key, defaultValue); + } + + @Override + public Long getLongParam(Map arguments, String timestamp, Long defaultValue) { + return super.getLongParam(arguments, timestamp, defaultValue); + } + + @Override + public String buildFullTopicName(Map arguments) { + return super.buildFullTopicName(arguments); + } + + @Override + public String resolveNamespace(Map arguments) { + return super.resolveNamespace(arguments); + } + + @Override + public void addTopicBreakdown(Map result, String fullTopicName) { + super.addTopicBreakdown(result, fullTopicName); + } + } +} diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServerTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServerTest.java new file mode 100644 index 0000000..4ffc962 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/HttpMCPServerTest.java @@ -0,0 +1,210 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import java.lang.reflect.Field; +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.PulsarClient; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class HttpMCPServerTest { + + @Mock + private PulsarClientManager mockPulsarClientManager; + + @Mock + private PulsarAdmin mockPulsarAdmin; + + @Mock + private PulsarClient mockPulsarClient; + + private HttpMCPServer server; + private AutoCloseable mocks; + + @BeforeMethod + public void setUp() { + this.mocks = MockitoAnnotations.openMocks(this); + this.server = new HttpMCPServer(); + } + + @AfterMethod + public void tearDown() throws Exception { + if (mocks != null) { + mocks.close(); + } + if (server != null) { + try { + server.stop(); + } catch (Exception ignore) { + + } + } + } + + @Test( + expectedExceptions = IllegalStateException.class, + expectedExceptionsMessageRegExp = ".*PulsarClientManager not injected.*" + ) + public void start_shouldThrowException_whenPulsarClientManagerNotInjected() throws Exception { + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test( + expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = ".*Failed to obtain PulsarAdmin from PulsarClientManager.*" + ) + public void start_shouldThrowException_whenPulsarAdminInitializationFails() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenThrow(new RuntimeException("Admin init failed")); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test( + expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = ".*Failed to obtain PulsarAdmin from PulsarClientManager.*" + ) + public void start_shouldThrowException_whenPulsarClientInitializationFails() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenThrow(new RuntimeException("Client init failed")); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test + public void start_shouldStartServerSuccessfully_whenMocked() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{"--port", "9999"}); + server.start(options); + org.testng.Assert.assertNotNull(server); + org.mockito.Mockito.verify(mockPulsarClientManager).getAdmin(); + org.mockito.Mockito.verify(mockPulsarClientManager).getClient(); + } + + @Test + public void start_shouldWarn_whenAlreadyRunning() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + try { + server.start(options); + server.start(options); + } catch (Exception e) { + org.testng.Assert.assertNotNull(e); + } + } + + @Test + public void stop_shouldStopServerSuccessfully() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + try { + server.start(options); + } catch (Exception e) { + + } + + server.stop(); + org.mockito.Mockito.verify(mockPulsarClientManager).close(); + } + + @Test + public void stop_shouldHandleWhenNotStarted() { + server.stop(); + } + + @Test + public void stop_shouldBeIdempotent() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + try { + server.start(options); + } catch (Exception e) { + + } + server.stop(); + server.stop(); + server.stop(); + + org.testng.Assert.assertNotNull(server); + } + + @Test + public void getType_shouldReturnHttp() { + org.testng.Assert.assertEquals(server.getType(), PulsarMCPCliOptions.TransportType.HTTP); + } + + @Test + public void stop_shouldClosePulsarClientManager() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + try { + server.start(options); + } catch (Exception e) { + + } + org.mockito.Mockito.verify(mockPulsarClientManager, org.mockito.Mockito.never()).close(); + + server.stop(); + + org.mockito.Mockito.verify(mockPulsarClientManager, org.mockito.Mockito.times(1)).close(); + } + + private void injectPulsarClientManager() throws Exception { + Field field = AbstractMCPServer.class.getDeclaredField("pulsarClientManager"); + field.setAccessible(true); + field.set(server, mockPulsarClientManager); + } +} diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServerTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServerTest.java new file mode 100644 index 0000000..d162972 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/StdioMCPServerTest.java @@ -0,0 +1,211 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import java.lang.reflect.Field; +import org.apache.pulsar.admin.mcp.client.PulsarClientManager; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.PulsarClient; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class StdioMCPServerTest { + + @Mock + private PulsarClientManager mockPulsarClientManager; + + @Mock + private PulsarAdmin mockPulsarAdmin; + + @Mock + private PulsarClient mockPulsarClient; + + private StdioMCPServer server; + private AutoCloseable mocks; + + @BeforeMethod + public void setUp() { + this.mocks = MockitoAnnotations.openMocks(this); + this.server = new StdioMCPServer(); + } + + @AfterMethod + public void tearDown() throws Exception { + if (mocks != null) { + mocks.close(); + } + if (server != null) { + try { + server.stop(); + } catch (Exception ignore) { + } + } + } + + @Test( + expectedExceptions = IllegalStateException.class, + expectedExceptionsMessageRegExp = ".*PulsarClientManager not injected.*" + ) + public void start_shouldThrowException_whenPulsarClientManagerNotInjected() { + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test( + expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = ".*Cannot start MCP server without Pulsar connection.*" + ) + public void start_shouldThrowException_whenPulsarAdminInitializationFails() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenThrow(new RuntimeException("Admin init failed")); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test( + expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = ".*Cannot start MCP server without Pulsar connection.*" + ) + public void start_shouldThrowException_whenPulsarClientInitializationFails() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenThrow(new RuntimeException("Client init failed")); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + } + + @Test + public void start_shouldStartServerSuccessfully() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + + org.testng.Assert.assertNotNull(server); + } + + @Test + public void start_shouldWarn_whenAlreadyRunning() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + server.start(options); + server.start(options); + + org.testng.Assert.assertNotNull(server); + } + + @Test + public void stop_shouldStopServerSuccessfully() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + + server.stop(); + org.mockito.Mockito.verify(mockPulsarClientManager).close(); + } + + @Test + public void stop_shouldHandleWhenNotStarted() { + server.stop(); + } + + @Test + public void stop_shouldBeIdempotent() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + + server.stop(); + server.stop(); + server.stop(); + + org.testng.Assert.assertNotNull(server); + } + + @Test + public void getType_shouldReturnStdio() { + org.testng.Assert.assertEquals(server.getType(), PulsarMCPCliOptions.TransportType.STDIO); + } + + @Test + public void start_shouldHandleConcurrentCalls() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + + server.start(options); + server.start(options); + server.start(options); + + org.testng.Assert.assertNotNull(server); + } + + @Test + public void stop_shouldClosePulsarClientManager() throws Exception { + injectPulsarClientManager(); + org.mockito.Mockito.when(mockPulsarClientManager.getAdmin()) + .thenReturn(mockPulsarAdmin); + org.mockito.Mockito.when(mockPulsarClientManager.getClient()) + .thenReturn(mockPulsarClient); + + PulsarMCPCliOptions options = PulsarMCPCliOptions.parseArgs(new String[]{}); + server.start(options); + + org.mockito.Mockito.verify(mockPulsarClientManager, org.mockito.Mockito.times(0)).close(); + + server.stop(); + + org.mockito.Mockito.verify(mockPulsarClientManager, org.mockito.Mockito.times(1)).close(); + } + + private void injectPulsarClientManager() throws Exception { + Field field = AbstractMCPServer.class.getDeclaredField("pulsarClientManager"); + field.setAccessible(true); + field.set(server, mockPulsarClientManager); + } +} + diff --git a/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/TransportManagerTest.java b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/TransportManagerTest.java new file mode 100644 index 0000000..d474460 --- /dev/null +++ b/pulsar-admin-mcp-contrib/src/test/java/org/apache/pulsar/admin/mcp/transport/TransportManagerTest.java @@ -0,0 +1,165 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.pulsar.admin.mcp.transport; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import org.apache.pulsar.admin.mcp.config.PulsarMCPCliOptions; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class TransportManagerTest { + + private TransportManager mgr; + private PulsarMCPCliOptions opts; + + @BeforeMethod + public void setUp() { + mgr = new TransportManager(); + opts = new PulsarMCPCliOptions(); + } + + @Test + public void startTransport_shouldInvokeRegisteredTransport_withPassedOptions() throws Exception { + Transport http = mock(Transport.class); + when(http.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + mgr.registerTransport(http); + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + + verify(http, times(1)).start(opts); + verify(http, times(1)).getType(); + verifyNoMoreInteractions(http); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = ".*Transport not registered.*" + ) + public void startTransport_shouldThrow_whenTransportNotRegistered() throws Exception { + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + } + + @Test + public void registerTransport_shouldOverridePrevious_whenSameTypeRegisteredAgain() throws Exception { + Transport t1 = mock(Transport.class); + when(t1.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + Transport t2 = mock(Transport.class); + when(t2.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + mgr.registerTransport(t1); + mgr.registerTransport(t2); + + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + + verify(t2, times(1)).start(opts); + verify(t1, never()).start(any()); + } + + @Test + public void registerTransport_shouldQueryTypeOnce_andStoreByType() { + Transport any = mock(Transport.class); + when(any.getType()).thenReturn(PulsarMCPCliOptions.TransportType.STDIO); + + mgr.registerTransport(any); + + verify(any, times(1)).getType(); + verifyNoMoreInteractions(any); + } + + @Test + public void registerTransport_shouldAllowMultipleDifferentTypes() throws Exception { + Transport stdio = mock(Transport.class); + when(stdio.getType()).thenReturn(PulsarMCPCliOptions.TransportType.STDIO); + + Transport http = mock(Transport.class); + when(http.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + mgr.registerTransport(stdio); + mgr.registerTransport(http); + + mgr.startTransport(PulsarMCPCliOptions.TransportType.STDIO, opts); + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + + verify(stdio, times(1)).start(opts); + verify(http, times(1)).start(opts); + } + + @Test + public void startTransport_shouldWorkAfterMultipleRegistrations() throws Exception { + Transport transport1 = mock(Transport.class); + when(transport1.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + Transport transport2 = mock(Transport.class); + when(transport2.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + mgr.registerTransport(transport1); + mgr.registerTransport(transport2); + + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + + verify(transport2, times(1)).start(opts); + verify(transport1, never()).start(any()); + } + + @Test + public void startTransport_shouldPassNullOptionsToTransport() throws Exception { + Transport http = mock(Transport.class); + when(http.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + + mgr.registerTransport(http); + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, null); + + verify(http, times(1)).start(null); + } + + @Test + public void startTransport_shouldCallStartMethod_afterRegistration() throws Exception { + Transport stdio = mock(Transport.class); + when(stdio.getType()).thenReturn(PulsarMCPCliOptions.TransportType.STDIO); + + mgr.registerTransport(stdio); + mgr.startTransport(PulsarMCPCliOptions.TransportType.STDIO, opts); + + verify(stdio, times(1)).start(opts); + } + + @Test + public void registerTransport_shouldHandleConcurrentRegistration() { + Transport transport1 = mock(Transport.class); + Transport transport2 = mock(Transport.class); + when(transport1.getType()).thenReturn(PulsarMCPCliOptions.TransportType.HTTP); + when(transport2.getType()).thenReturn(PulsarMCPCliOptions.TransportType.STDIO); + + mgr.registerTransport(transport1); + mgr.registerTransport(transport2); + + verify(transport1, times(1)).getType(); + verify(transport2, times(1)).getType(); + } + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = ".*Transport not registered.*" + ) + public void startTransport_shouldThrow_whenTransportTypeNotRegistered() throws Exception { + mgr.startTransport(PulsarMCPCliOptions.TransportType.HTTP, opts); + } +}