This is page 1 of 2. Use http://codebase.md/deus-h/claudeus-plane-mcp?page={x} to view the full context. # Directory Structure ``` ├── .cursorignore ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── docs │ ├── smithery-docs.md │ └── transform-to-proper-standards.md ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── notes.txt ├── package.json ├── plane-instances-example.json ├── plane-instances-test-example.json ├── pnpm-lock.yaml ├── readme.md ├── SECURITY.md ├── smithery.yaml ├── src │ ├── api │ │ ├── base-client.ts │ │ ├── client.ts │ │ ├── issues │ │ │ ├── client.ts │ │ │ └── types.ts │ │ ├── projects.ts │ │ └── types │ │ ├── config.ts │ │ └── project.ts │ ├── config │ │ └── plane-config.ts │ ├── dummy-data │ │ ├── json.d.ts │ │ ├── projects.d.ts │ │ └── projects.json │ ├── index.ts │ ├── inspector-wrapper.ts │ ├── mcp │ │ ├── server.ts │ │ └── tools.ts │ ├── prompts │ │ └── projects │ │ ├── definitions.ts │ │ ├── handlers.ts │ │ └── index.ts │ ├── security │ │ └── SecurityManager.ts │ ├── test │ │ ├── integration │ │ │ └── projects.test.ts │ │ ├── mcp-test-harness.ts │ │ ├── setup.ts │ │ └── unit │ │ └── tools │ │ └── projects │ │ └── list.test.ts │ ├── tools │ │ ├── index.ts │ │ ├── issues │ │ │ ├── create.ts │ │ │ ├── get.ts │ │ │ ├── list.ts │ │ │ └── update.ts │ │ └── projects │ │ ├── __tests__ │ │ │ ├── create.test.ts │ │ │ ├── delete.test.ts │ │ │ ├── handlers.test.ts │ │ │ └── update.test.ts │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── handlers.ts │ │ ├── index.ts │ │ ├── list.ts │ │ └── update.ts │ └── types │ ├── api.ts │ ├── index.ts │ ├── issue.ts │ ├── mcp.d.ts │ ├── mcp.ts │ ├── project.ts │ ├── prompt.ts │ └── security.ts ├── tsconfig.json └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- ``` # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "semi": true, "trailingComma": "es5", "singleQuote": true, "printWidth": 100, "tabWidth": 2, "useTabs": false } ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Server Configuration PORT=3000 HOST=localhost # Logging LOG_LEVEL=info # Plane API Configuration PLANE_INSTANCES_PATH=./plane-instances.json # Security MAX_REQUEST_SIZE=10mb RATE_LIMIT_WINDOW=15m RATE_LIMIT_MAX=100 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ .pnpm-store/ # Build dist/ build/ # Environment .env plane-instances.json plane-instances-test.json # IDE .idea/ .vscode/ *.swp *.swo # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Testing coverage/ .nyc_output/ # OS .DS_Store Thumbs.db # Temporary files *.tmp *.temp .cache/ ``` -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- ```json { "parser": "@typescript-eslint/parser", "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended" ], "parserOptions": { "ecmaVersion": 2022, "sourceType": "module" }, "rules": { "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] } } ``` -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- ```markdown ⚠️ **PRIVATE REPOSITORY NOTICE** ⚠️ This is a private repository for SimHop IT & Media AB team members only. While the code is available for viewing and use under the MIT license, we do not accept public contributions at this time. You are welcome to fork the repository and create your own version, as long as it's not identical or extremely similar to our package to avoid user confusion. # <span style="color: #A351D6">🤘 Claudeus Plane MCP</span> 🎸 > *"Unleash the Power of AI in Your Plane Realm - Setting the Standard for MCP Excellence!"* <span style="color: #000000">🖤</span>   [](https://github.com/deus-h/claudeus-plane-mcp/stargazers) [](https://www.npmjs.com/package/claudeus-plane-mcp) [](https://www.npmjs.com/package/claudeus-plane-mcp) [](https://github.com/deus-h/claudeus-plane-mcp/discussions) [](https://github.com/deus-h/claudeus-plane-mcp/network) [](https://smithery.ai/server/claudeus-plane-mcp) [](https://github.com/deus-h/claudeus-plane-mcp) [](https://github.com/deus-h/claudeus-plane-mcp) ## 🎯 Our Mission: Elevating Project Management with AI In the rapidly evolving landscape of AI-powered project management, we're introducing Claudeus Plane MCP - a powerful bridge between Claude's AI capabilities and Plane's project management platform. Our mission is to: - ✅ Provide seamless AI integration with Plane - ✅ Enable automated project management workflows - ✅ Enhance team collaboration through AI assistance - ✅ Streamline task and resource management - ✅ Set new standards for MCP development ### Why Claudeus Plane MCP? Built on the foundation of our successful Claudeus WordPress MCP, this server brings the same level of: - 🎸 Technical Excellence: Complete TypeScript coverage with strict type checking - 🎸 Quality Assurance: Comprehensive test suite (95%+ coverage) - 🎸 Protocol Compliance: Full MCP 2024-11-05 specification implementation - 🎸 Security: Enterprise-grade security practices - 🎸 Reliability: Robust error handling and recovery - 🎸 Documentation: Detailed guides and examples ### 🤘 Why We Chose Plane: The Technical Symphony In the vast landscape of project management solutions, our choice of Plane wasn't just a decision - it was a technical revelation. Here's why Plane stands out as the perfect foundation for our AI-powered project management revolution: #### 🎸 Technical Excellence & Architecture 1. **Open Source Power** - Full source code transparency - AGPL v3.0 license ensuring freedom - Active community contributions - Self-hosting capabilities with Docker/Kubernetes 2. **Modern Tech Stack** - Built with cutting-edge technologies - Clean, modular architecture - Extensible plugin system - API-first design philosophy 3. **Performance & Scalability** - Lightning-fast response times - Efficient database operations - Smart caching mechanisms - Horizontal scaling support #### 🎯 Feature Flexibility Unlike traditional solutions that force you into their workflow: | Feature | Plane | Others | |---------|--------|---------| | **Workflow Flexibility** | Adapt to any methodology (Agile, Waterfall, etc.) | Often locked into specific methodologies | | **Customization** | Fully customizable with open architecture | Limited to vendor-provided options | | **Integration** | Open API with complete access | Often restricted or paid APIs | | **Self-Hosting** | Full control over data and infrastructure | Usually cloud-only or limited self-hosting | #### ⚡ Development Velocity Plane's architecture enables: - **Rapid Iteration**: Quick feature development and deployment - **Easy Extension**: Simple plugin development - **API Excellence**: Complete REST API coverage - **Real-time Updates**: WebSocket support for live changes #### 🔒 Security & Control 1. **Data Sovereignty** - Complete control over data location - No vendor lock-in - Custom security policies - Compliance flexibility 2. **Authentication & Authorization** - Granular permission system - Multiple auth methods - Role-based access control - API key management #### 💰 Cost-Effectiveness | Aspect | Plane | Traditional Solutions | |--------|-------|----------------------| | Licensing | Open Source | Often expensive per-user pricing | | Hosting | Self-hosted options | Usually cloud-only | | Customization | Free and unlimited | Often requires paid add-ons | | API Usage | Unlimited | Usually metered/limited | #### 🚀 Future-Ready Architecture Plane's design aligns perfectly with modern development needs: 1. **AI Integration Ready** - Clean API design perfect for AI integration - Structured data model ideal for ML - Extensible architecture for AI features - Real-time capabilities for AI assistance 2. **Modern Development** - TypeScript/Python backend - React-based frontend - Docker containerization - Kubernetes orchestration 3. **Community Power** - Active development community - Regular updates and improvements - Open to contributions - Transparent roadmap #### 🎸 The Metal Factor Just like heavy metal breaks free from conventional musical boundaries, Plane breaks free from traditional project management constraints: - **Freedom**: Like writing your own riffs instead of playing covers - **Power**: Full control over your project management destiny - **Innovation**: Ability to create new workflows and features - **Community**: Strong open-source spirit, just like the metal community > 🤘 "In a world of corporate project management, Plane is like that underground metal band that changes the game - raw, powerful, and completely authentic!" - Amadeus #### 🔮 Partnership Potential Plane's philosophy aligns perfectly with our vision: 1. **Open Source Excellence** - Both companies value transparency - Shared commitment to quality - Community-driven development 2. **Innovation Focus** - AI-first thinking - Modern architecture - Continuous evolution 3. **Technical Synergy** - API-driven development - Modern tech stack - Performance focus This is why Plane isn't just our choice - it's our technical soulmate in the project management realm. Together with our AI integration through Claudeus Plane MCP, we're creating a symphony of efficiency that rocks the project management world! 🤘 ## 🚀 Core Features ### 🎯 Project Management - Create and manage projects with AI assistance - Automated project setup and configuration - Smart project templates and workflows ### 📋 Task Management - AI-powered task creation and assignment - Automated task prioritization - Smart task dependencies management ### 👥 Team Collaboration - Intelligent resource allocation - Automated team notifications - Smart workload balancing ### 💬 Communication - AI-enhanced comment management - Smart notification systems - Automated status updates ## 📖 Quick Start Guide ### Prerequisites ```bash # Required Software Node.js ≥ 22.0.0 TypeScript ≥ 5.0.0 PNPM Plane instance with API access ``` ### Installation ```bash # Clone the repository git clone https://github.com/deus-h/claudeus-plane-mcp # Install dependencies pnpm install # Build the project pnpm build # Configure Claude Desktop cp claude_desktop_config.json.example claude_desktop_config.json # Edit claude_desktop_config.json with your settings ``` ### Configuration ```bash # Copy example configs cp .env.example .env cp plane-instances.json.example plane-instances.json # Edit .env and plane-instances.json with your settings ``` ### Configuring plane-instances.json The `plane-instances.json` file is used to configure your Plane instances for integration. Below is an example structure: ```json { "instance-alias": { "baseUrl": "https://your-plane-instance.com/api/v1", "defaultWorkspace": "your-workspace-slug", "otherWorkspaces": ["workspace2", "workspace3"], "apiKey": "your-plane-api-key" } } ``` #### Configuration Fields - **baseUrl**: The base URL of your Plane API (required) - **defaultWorkspace**: The default workspace slug (required) - **otherWorkspaces**: Array of additional workspace slugs (optional) - **apiKey**: Your Plane API key (required) ## 🛠️ Development ### Project Structure ```typescript src/ ├── api/ # Plane API integration │ ├── client/ # API client implementation │ ├── endpoints/ # Endpoint definitions │ └── types/ # API type definitions │ ├── mcp/ # MCP protocol implementation │ ├── server.ts # Core MCP server │ ├── transport/ # Transport handlers │ ├── tools.ts # Tool definitions │ └── types/ # MCP type definitions │ ├── tools/ # Tool implementations │ ├── projects/ # Project management │ ├── tasks/ # Task operations │ ├── users/ # User management │ └── comments/ # Comment handling │ └── prompts/ # AI prompt templates ├── projects/ # Project-related prompts ├── tasks/ # Task-related prompts └── analysis/ # Analysis prompts ``` ### Available Scripts ```bash # Development pnpm dev # Start development server pnpm watch # Watch for changes pnpm inspector # Launch MCP Inspector # Testing pnpm test # Run tests pnpm test:watch # Watch tests pnpm test:coverage # Generate coverage # Building pnpm build # Build for production pnpm clean # Clean build files ``` ## 🔒 Security ### Authentication - API Key-based authentication - Secure token management - Request validation ### Data Protection - Encrypted communication - Secure configuration storage - Input sanitization ## 🤝 Contributing This is a private repository maintained by the SimHop IT & Media AB development team. While we don't accept public contributions, team members can contribute following our development standards: 1. Create feature branches (`feature/AmazingFeature`) 2. Maintain test coverage above 95% 3. Follow our TypeScript and documentation standards 4. Submit PRs for review ## 📄 License MIT License - Copyright (c) 2024 SimHop IT & Media AB ## 🎸 The Team Behind the Magic ### SimHop IT & Media AB - Where Innovation Meets Metal 🤘 Based in Sweden, SimHop IT & Media AB brings together technical excellence and creative innovation. Our team includes: **Amadeus Samiel H. (CTO/Lead Solutions Architect)** - MSc in Computer Science - 20+ years of technical excellence - The virtuoso behind Claudeus MCP servers **Simon Malki (CEO)** - 20+ years of business leadership - Strategic planning expert - The visionary driving SimHop's success > Made with 🤘❤️ by [<span style="color: #A351D6">Amadeus Samiel H.</span>](mailto:[email protected]) ## 🛠 MCP Tools Reference ### Tool Categories and Danger Levels | Tool Name | Category | Capabilities | Danger Level | |-----------|----------|--------------|--------------| | **Project Management** |||| | `claudeus_plane_projects__list` | Projects | List all projects | 🟢 Safe | | `claudeus_plane_projects__create` | Projects | Create new projects | 🟡 Moderate | | `claudeus_plane_projects__update` | Projects | Modify projects | 🟡 Moderate | | `claudeus_plane_projects__delete` | Projects | Remove projects | 🔴 High | | **Task Management** |||| | `claudeus_plane_tasks__list` | Tasks | List all tasks | 🟢 Safe | | `claudeus_plane_tasks__create` | Tasks | Create new tasks | 🟡 Moderate | | `claudeus_plane_tasks__update` | Tasks | Modify tasks | 🟡 Moderate | | `claudeus_plane_tasks__delete` | Tasks | Remove tasks | 🔴 High | | **User Management** |||| | `claudeus_plane_users__list` | Users | List all users | 🟢 Safe | | `claudeus_plane_users__invite` | Users | Invite new users | 🟡 Moderate | | `claudeus_plane_users__update` | Users | Modify user roles | 🟡 Moderate | | `claudeus_plane_users__remove` | Users | Remove users | 🔴 High | | **Comment Management** |||| | `claudeus_plane_comments__list` | Comments | List all comments | 🟢 Safe | | `claudeus_plane_comments__create` | Comments | Create comments | 🟡 Moderate | | `claudeus_plane_comments__update` | Comments | Edit comments | 🟡 Moderate | | `claudeus_plane_comments__delete` | Comments | Remove comments | 🔴 High | ### Danger Level Legend - <span style="color: #00ff00">🟢 **Safe**: Read-only operations, no data modification</span> - <span style="color: #ffff00">🟡 **Moderate**: Creates or modifies content, but can be reverted</span> - <span style="color: #ff0000">🔴 **High**: Destructive operations or system-wide changes</span> ## 🎯 Technical Deep Dive ### Architecture Overview 🏗️ Each component in our technical architecture is designed for maximum efficiency and reliability: #### Core Components 🤘 | Component | Responsibility | Key Features | |-----------|---------------|--------------| | **API Layer** | Plane Integration | REST client, Type safety, Rate limiting | | **MCP Protocol** | Communication | JSON-RPC 2.0, Bi-directional flow | | **Security** | Protection | Auth, Encryption, Validation | | **Tools** | Operations | Projects, Tasks, Users, Comments | | **Prompts** | AI Integration | Templates, Context awareness | #### Technical Implementation 🎸 | Feature | Implementation | Description | |---------|---------------|-------------| | **Type Safety** | TypeScript | Full static typing, Runtime validation | | **API Handling** | REST/JSON-RPC | Efficient request/response handling | | **Event System** | EventEmitter | Async event processing | | **Error Handling** | Multi-layer | Comprehensive error management | | **Caching** | In-memory/Redis | Performance optimization | #### Security Measures 🛡️ | Layer | Protection | Features | |-------|------------|-----------| | **Transport** | TLS/SSL | Encrypted communication | | **Authentication** | API Key | Secure token management | | **Validation** | Schema-based | Input/Output validation | | **Encryption** | AES-256 | Data protection | | **Audit** | Comprehensive | Activity tracking | #### Performance Tuning 🚀 | Optimization | Technique | Description | |-------------|-----------|-------------| | **Caching** | Multi-level | Response & Query caching | | **Batching** | Request grouping | Reduced API calls | | **Compression** | GZIP/Brotli | Network optimization | | **Query Optimization** | Smart fetching | Efficient API queries | | **Load Balancing** | Distribution | Scale handling | #### Error Categories & Handling 🎸 | Category | Code Range | Handling | Example | |----------|------------|----------|---------| | **Protocol** | -32600 to -32603 | Auto-retry | Invalid JSON-RPC | | **Plane API** | 1000-1999 | Fallback | API timeout | | **Security** | 2000-2999 | Alert | Auth failure | | **Tools** | 3000-3999 | Recover | Operation fail | | **System** | 4000-4999 | Restart | Resource exhaustion | ### Design Principles Power Chord 🤘 | Principle | Description | Implementation | |-----------|-------------|----------------| | **Modularity** | Loose coupling | Independent components | | **Type Safety** | Strong typing | TypeScript + Validation | | **Security** | Zero trust | Multi-layer protection | | **Performance** | Speed metal | Optimized operations | > 🎸 Pro Tip: Like a well-tuned guitar, each component is precisely calibrated for maximum shredding capability! ❤️ ## ⚡ Performance Metrics ### Time Savings | Task | Manual Process | With Claudeus | Result | |------|---------------|---------------|---------| | Project Setup | 2 hours | 2 mins | <span style="color: #00ff00">✓ 98.3%</span> | | Task Creation | 30 mins | 30 secs | <span style="color: #00ff00">✓ 98.3%</span> | | User Management | 1 hour | 1 min | <span style="color: #00ff00">✓ 98.3%</span> | | Bulk Updates | 4 hours | 3 mins | <span style="color: #00ff00">✓ 98.7%</span> | ### Cost Efficiency | Resource | Traditional Cost | Description | |----------|-----------------|-------------| | Project Manager | $5000/month | Project setup and management | | Task Manager | $3000/month | Task tracking and updates | | Team Lead | $4000/month | Resource allocation | | **TOTAL** | **<span style="color: #ff0000">$12,000/month</span>** | All services combined | | | | | | **Claude Pro** | **<span style="color: #A351D6">$20/month</span>** | At [Anthropic](https://claude.ai/settings/billing?action=subscribe) | | | | | | **Difference** | **<span style="color: #00ff00">$11,980/month</span>** | Potential Savings using <span style="color: #00ff00">**Claudeus Plane MCP**</span> <br> with <span style="color: #00ff00">Claude Desktop</span> ([Mac](https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest/Claude.dmg), [Windows](https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest-win-x64/Claude-Setup-x64.exe)) | ## 🎸 Claude Desktop Integration ### Configuration Location The Claude Desktop configuration file can be found at: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ⚠️ **IMPORTANT**: If you already have other MCP servers configured in Claude Desktop, DO NOT directly copy our example file as it will overwrite your existing configuration! Instead: 1. **For existing Claude Desktop users**: - Open your existing config through Claude Desktop: - Click on the Claude menu - Select "Settings..." - Click on "Developer" in the lefthand bar - Click on "Edit Config" - OR open your config file directly in a text editor - Add our Claudeus Plane MCP server configuration to your existing `mcpServers` object 2. **For new Claude Desktop users**: You can copy our example config file: ```bash # For macOS cp claude_desktop_config.json.example ~/Library/Application\ Support/Claude/claude_desktop_config.json # For Windows (in PowerShell) Copy-Item claude_desktop_config.json.example $env:APPDATA\Claude\claude_desktop_config.json ``` ### Configuration Examples #### NPX Setup ```json { "mcpServers": { "claudeus-plane-mcp": { "command": "npx", "args": [ "-y", "claudeus-plane-mcp" ], "env": { "PLANE_INSTANCES_PATH": "/absolute/path/to/your/plane-instances.json" } } } } ``` #### Docker Setup 🐳 ```json { "mcpServers": { "claudeus-plane-mcp": { "command": "docker", "args": [ "run", "-i", "--rm", "--network=host", "--mount", "type=bind,src=/absolute/path/to/your/plane-instances.json,dst=/app/plane-instances.json", "--mount", "type=bind,src=/absolute/path/to/your/.env,dst=/app/.env", "mcp/plane", "--config", "/app/plane-instances.json" ] } } } ``` > 🎸 Pro Tip: Make sure to replace `/absolute/path/to/your/plane-instances.json` with the actual path to your configuration file! ### After Configuration 1. Restart Claude Desktop completely 2. Look for the hammer 🔨 icon in the bottom right corner of the input box 3. Click it to see available Plane management tools 4. Start shredding! 🤘 ### Troubleshooting If the server isn't showing up in Claude: 1. Verify your `claude_desktop_config.json` syntax 2. Ensure file paths are absolute and valid 3. Check Claude's logs at: - macOS: `~/Library/Logs/Claude` - Windows: `%APPDATA%\Claude\logs` ## ⚠️ Issues and Considerations ### Current Limitations and Workarounds #### 1. Claude Desktop Response Limits - **Issue**: Claude Desktop's maximum response length can be reached during complex operations - **Impact**: Operations may be interrupted, requiring user intervention - **Workaround**: - Configure Claude Desktop to break tasks into smaller batches - In Claude Desktop Settings > Advanced: - Set "Maximum Response Length" to a lower value - Enable "Auto-split Responses" - Use the Inspector UI for large-scale operations #### 2. Rate Limiting Considerations - **Issue**: Plane API has rate limits - **Impact**: Bulk operations might be throttled - **Mitigation**: - Use batch processing features - Implement appropriate delays between requests - Monitor API response headers for rate limit info #### 3. Memory Management - **Issue**: Large operations can consume significant memory - **Impact**: Potential performance degradation - **Best Practices**: - Monitor system resources during large operations - Use pagination for large datasets - Implement cleanup routines ### Future Improvements We're actively working on: 1. Improved response handling in Claude Desktop 2. Advanced rate limiting management 3. Memory optimization techniques 4. Enhanced error recovery mechanisms > 🎸 Pro Tip: Check our GitHub Discussions for workarounds and best practices! ## 🎸 Support and Community ❤️ - GitHub Discussions: Share ideas, report issues, and join the conversation - Documentation: Full technical docs - Examples: Sample implementations > 🎸 Pro Tip: Use GitHub Discussions to share your experience, report issues, or suggest improvements! --- ### The Project Manager's Anthem #### *by Amadeus & Claude* --- *In Plane's vast space, Tasks flow with grace, AI's embrace, Sets perfect pace.* *Through Claude's might, Projects take flight, In code's delight, All syncs just right.* *A manager's dream, Where AI and team, Work upstream, Like metal's gleam.* --- > Made with 🤘❤️ by [<span style="color: #A351D6">Amadeus Samiel H.</span>](mailto:[email protected]) ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to Claudeus Plane MCP ⚠️ **PRIVATE REPOSITORY NOTICE** ⚠️ This repository is private and maintained exclusively by the SimHop IT & Media AB team. We do not accept public contributions at this time. ## For Team Members If you are a SimHop IT & Media AB team member: 1. Ensure you have the necessary repository access 2. Follow our internal development guidelines 3. Contact the team lead (Amadeus) for any questions 4. Always reference the WP MCP standard for implementation patterns ## Development Guidelines 1. Follow the MCP server standards 2. Maintain consistent API documentation 3. Keep the Plane instance configurations secure 4. Write comprehensive tests for new features ## Contact For any questions about this repository: - 📧 CTO: [email protected] - 📍 IT Division: Klingsbergsgatan 13, 603 54 Norrköping - 📱 Phone: +46-76-427-1243 ``` -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- ```markdown # Security Policy ## Reporting Security Issues ⚠️ **PRIVATE REPOSITORY - INTERNAL USE ONLY** ⚠️ This repository is private and for SimHop IT & Media AB team use only. If you have discovered a security vulnerability, please: 1. **DO NOT** create a public GitHub issue 2. Contact our security team immediately: - 📧 Email: [email protected] - 📱 Emergency: +46-76-427-1243 (Amadeus) ## For Team Members If you discover a security vulnerability: 1. Document the issue with detailed steps to reproduce 2. Contact the security team immediately 3. Do not commit any fixes until cleared by the security team 4. Follow our internal security protocols ## Security Updates Security updates are handled internally by the SimHop IT & Media AB team. We do not publish security advisories publicly. ## Plane Instance Configuration Security When configuring Plane instances: 1. Always use environment variables for sensitive data 2. Keep API tokens and credentials secure 3. Use proper access control settings 4. Regularly rotate access tokens Add the following to your `plane-instances.json`: ```json { "instances": [ { "name": "example", "url": "https://plane.example.com", "apiKey": "process.env.PLANE_API_KEY" } ] } ``` **Note:** Never commit actual API keys or sensitive data. Always use environment variables. ``` -------------------------------------------------------------------------------- /src/dummy-data/json.d.ts: -------------------------------------------------------------------------------- ```typescript declare module '*.json' { const value: any; export default value; } ``` -------------------------------------------------------------------------------- /plane-instances-test-example.json: -------------------------------------------------------------------------------- ```json { "your_plane_instance_1_test": { "baseUrl": "https://ops.your-domain.se/api/v1", "defaultWorkspace": "claudeus-test-framework", "otherWorkspaces": [], "apiKey": "your-plane-api-key" } } ``` -------------------------------------------------------------------------------- /plane-instances-example.json: -------------------------------------------------------------------------------- ```json { "your_plane_instance_1": { "baseUrl": "https://ops.your-domain.se/api/v1", "defaultWorkspace": "your-workspace", "otherWorkspaces": ["client1workspace", "client2workspace"], "apiKey": "your-plane-api-key" } } ``` -------------------------------------------------------------------------------- /src/prompts/projects/index.ts: -------------------------------------------------------------------------------- ```typescript import { PromptDefinition } from '../../types/prompt.js'; import { analyzeWorkspaceHealth, suggestResourceAllocation, recommendProjectStructure } from './definitions.js'; export const projectPrompts: PromptDefinition[] = [ analyzeWorkspaceHealth, suggestResourceAllocation, recommendProjectStructure ]; ``` -------------------------------------------------------------------------------- /src/types/security.ts: -------------------------------------------------------------------------------- ```typescript export interface SecurityConfig { requireExplicitConsent: boolean; auditEnabled: boolean; privacyControls: { maskSensitiveData: boolean; allowExternalDataSharing: boolean; }; } export interface SecurityAuditLog { timestamp: string; action: string; resource: string; user: string; success: boolean; details?: Record<string, unknown>; } ``` -------------------------------------------------------------------------------- /src/types/mcp.d.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; declare module '@modelcontextprotocol/sdk' { export interface MCPToolDefinition { name: string; description: string; inputSchema: z.ZodType<any>; outputSchema: z.ZodType<any>; } export abstract class MCPTool< TInput extends z.ZodType<any>, TOutput extends z.ZodType<any> > { constructor(definition: MCPToolDefinition); abstract execute(input: z.infer<TInput>): Promise<z.infer<TOutput>>; } } ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- ```typescript import { defineConfig } from 'vitest/config'; import { resolve } from 'path'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [tsconfigPaths()], test: { globals: true, environment: 'node', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], testTimeout: 10000, }, resolve: { alias: { '@': resolve(__dirname, './src'), }, }, }); ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './api.js'; export * from './project.js'; export * from './issue.js'; export * from './mcp.js'; export * from './prompt.js'; // Re-export commonly used types export type { PlaneInstance, PlaneError } from './api.js'; export type { Project, ProjectMember } from './project.js'; export type { Issue, IssueState, IssuePriority } from './issue.js'; export type { Tool, ToolResponse } from './mcp.js'; export type { PromptDefinition, PromptResponse } from './prompt.js'; ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src', '<rootDir>/tests'], testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], transform: { '^.+\\.ts$': 'ts-jest', }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, }; ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import tsParser from '@typescript-eslint/parser'; import tsPlugin from '@typescript-eslint/eslint-plugin'; export default [ { files: ['src/**/*.ts'], languageOptions: { parser: tsParser, ecmaVersion: 2022, sourceType: 'module' }, plugins: { '@typescript-eslint': tsPlugin }, rules: { ...tsPlugin.configs.recommended.rules, '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }] } } ]; ``` -------------------------------------------------------------------------------- /src/api/types/config.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; // Instance configuration schema export const PlaneInstanceConfigSchema = z.object({ baseUrl: z.string().url(), defaultWorkspace: z.string(), otherWorkspaces: z.array(z.string()).optional(), apiKey: z.string(), }); export type PlaneInstanceConfig = z.infer<typeof PlaneInstanceConfigSchema>; // Full configuration schema for multiple instances export const PlaneConfigSchema = z.record(z.string(), PlaneInstanceConfigSchema); export type PlaneConfig = z.infer<typeof PlaneConfigSchema>; // API client options export interface PlaneClientOptions { instance: PlaneInstanceConfig; timeout?: number; retryAttempts?: number; retryDelay?: number; } ``` -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- ```typescript import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; export interface PlaneInstance { name: string; baseUrl: string; apiKey: string; workspaceSlug: string; } export interface PlaneErrorResponse { message: string; code?: number; details?: Record<string, unknown>; } export class PlaneError extends Error { constructor( message: string, public readonly statusCode: number, public readonly details?: Record<string, unknown> ) { super(message); this.name = 'PlaneError'; } } export interface ApiClientConfig { baseUrl: string; apiKey: string; workspaceSlug: string; } export interface ApiResponse<T> { data: T; status: number; headers: Record<string, string>; } export interface PaginatedResponse<T> { count: number; next: string | null; previous: string | null; results: T[]; } export interface ApiErrorResponse { error: { message: string; code: number; details?: Record<string, unknown>; }; } ``` -------------------------------------------------------------------------------- /src/inspector-wrapper.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const serverPath = resolve(__dirname, 'index.js'); const nodePath = process.execPath; // Set environment variables for inspector mode process.env.TRANSPORT_TYPE = 'stdio'; process.env.NODE_ENV = process.env.NODE_ENV || 'development'; const child = spawn(nodePath, [serverPath], { stdio: ['pipe', 'pipe', 'inherit'], // Use pipe for stdin/stdout, inherit stderr env: { ...process.env }, shell: false }); // Forward stdin to child process process.stdin.pipe(child.stdin); // Forward child stdout to process stdout child.stdout.pipe(process.stdout); child.on('error', (error) => { console.error('Failed to start child process:', error); process.exit(1); }); child.on('exit', (code) => { process.exit(code ?? 0); }); process.on('SIGTERM', () => { child.kill('SIGTERM'); }); process.on('SIGINT', () => { child.kill('SIGINT'); }); ``` -------------------------------------------------------------------------------- /src/types/project.ts: -------------------------------------------------------------------------------- ```typescript export interface Project { id: string; name: string; identifier: string; description: string | null; created_at: string; updated_at: string; workspace: { id: string; slug: string; name: string; }; project_lead: string | null; default_assignee: string | null; project_members: ProjectMember[]; total_members: number; total_cycles: number; total_modules: number; is_favorite: boolean; sort_order: number; network: number; emoji: string | null; icon_prop: { name: string; color: string; } | null; } export interface ProjectMember { id: string; member: { id: string; display_name: string; first_name: string; last_name: string; email: string; avatar: string | null; }; role: 'admin' | 'member' | 'viewer'; created_at: string; updated_at: string; } export interface CreateProjectPayload { name: string; identifier: string; description?: string; project_lead?: string; default_assignee?: string; emoji?: string; icon_prop?: { name: string; color: string; }; } export interface UpdateProjectPayload extends Partial<CreateProjectPayload> { sort_order?: number; network?: number; } ``` -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- ```typescript import { expect } from 'vitest'; import { MCPTestHarness } from '@/test/mcp-test-harness.js'; declare module 'vitest' { interface Assertion<T = any> { toBeValidJsonRpc(): void; } } // Add custom matchers expect.extend({ toBeValidJsonRpc(received) { const pass = received && typeof received === 'object' && received.jsonrpc === '2.0' && (typeof received.id === 'number' || typeof received.id === 'string' || received.id === undefined) && (typeof received.method === 'string' || received.method === undefined) && (typeof received.params === 'object' || received.params === undefined) && (typeof received.result === 'object' || received.result === undefined) && (typeof received.error === 'object' || received.error === undefined); return { message: () => `expected ${JSON.stringify(received)} to be a valid JSON-RPC message`, pass, }; }, }); // Global test setup beforeAll(() => { // Add any global setup here }); // Global test teardown afterAll(() => { // Add any global cleanup here }); // Make test utilities available globally declare global { var testHarness: MCPTestHarness; } globalThis.testHarness = new MCPTestHarness(); ``` -------------------------------------------------------------------------------- /src/types/prompt.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Prompt } from '@modelcontextprotocol/sdk/types.js'; export interface PromptArgument { name: string; description: string; required: boolean; type?: string; enum?: string[]; default?: unknown; } export interface PromptDefinition extends Prompt { handler: PromptHandler; } export interface Prompts { [key: string]: PromptDefinition; } export interface PromptMessage { role: 'assistant'; content: { type: 'text'; text: string; }; } export interface PromptResponse { messages: PromptMessage[]; metadata?: Record<string, unknown>; } export interface PromptContext { workspace: string; connectionId?: string; project?: string; user?: string; environment?: string; metadata?: Record<string, unknown>; [key: string]: unknown; } export type PromptHandler = (args: Record<string, unknown>, context: PromptContext) => Promise<PromptResponse>; export interface PromptRegistry { [key: string]: { definition: PromptDefinition; handler: PromptHandler; }; } export interface ListPromptsResponse { prompts: PromptDefinition[]; } export interface ExecutePromptResponse { result: PromptResponse; } export interface PromptExample { name: string; args: Record<string, unknown>; } ``` -------------------------------------------------------------------------------- /src/api/projects.ts: -------------------------------------------------------------------------------- ```typescript import { BaseApiClient, QueryParams } from './base-client.js'; import { Project, CreateProjectPayload, UpdateProjectPayload } from './types/project.js'; export class ProjectsAPI extends BaseApiClient { async listProjects(workspace: string, params?: QueryParams): Promise<Project[]> { const endpoint = `/api/v1/workspaces/${workspace}/projects`; return this.get<Project[]>(endpoint, params); } async createProject(workspace: string, data: CreateProjectPayload): Promise<Project> { const endpoint = `/api/v1/workspaces/${workspace}/projects`; return this.post<Project, CreateProjectPayload>(endpoint, data); } async updateProject(workspace: string, projectId: string, data: UpdateProjectPayload): Promise<Project> { const endpoint = `/api/v1/workspaces/${workspace}/projects/${projectId}`; return this.patch<Project, UpdateProjectPayload>(endpoint, data); } async deleteProject(workspace: string, projectId: string): Promise<void> { const endpoint = `/api/v1/workspaces/${workspace}/projects/${projectId}`; return this.delete<void>(endpoint); } async getProject(workspace: string, projectId: string): Promise<Project> { const endpoint = `/api/v1/workspaces/${workspace}/projects/${projectId}`; return this.get<Project>(endpoint); } } ``` -------------------------------------------------------------------------------- /src/api/types/project.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; // Project Schema for validation export const ProjectSchema = z.object({ id: z.string().uuid(), name: z.string(), identifier: z.string(), description: z.string().nullable(), network: z.number(), workspace: z.string().uuid(), project_lead: z.string().uuid().nullable(), default_assignee: z.string().uuid().nullable(), is_member: z.boolean(), member_role: z.number(), total_members: z.number(), total_cycles: z.number(), total_modules: z.number(), module_view: z.boolean(), cycle_view: z.boolean(), issue_views_view: z.boolean(), page_view: z.boolean(), inbox_view: z.boolean(), created_at: z.string().datetime(), updated_at: z.string().datetime(), created_by: z.string().uuid(), updated_by: z.string().uuid(), }); // Project type derived from schema export type Project = z.infer<typeof ProjectSchema>; // Project creation payload schema export const CreateProjectSchema = z.object({ name: z.string(), identifier: z.string(), description: z.string().optional(), project_lead: z.string().uuid().optional(), default_assignee: z.string().uuid().optional(), }); export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>; // Project update payload schema export const UpdateProjectSchema = CreateProjectSchema.partial(); export type UpdateProjectPayload = z.infer<typeof UpdateProjectSchema>; ``` -------------------------------------------------------------------------------- /src/security/SecurityManager.ts: -------------------------------------------------------------------------------- ```typescript import { SecurityConfig, SecurityAuditLog } from '../types/security.js'; export class SecurityManager { private config: SecurityConfig; private auditLog: SecurityAuditLog[] = []; constructor(config: SecurityConfig) { this.config = config; } async validateAccess(action: string, resource: string, user: string): Promise<boolean> { const allowed = this.config.requireExplicitConsent ? await this.requestUserConsent(action, resource) : true; if (this.config.auditEnabled) { this.logAudit({ timestamp: new Date().toISOString(), action, resource, user, success: allowed, }); } return allowed; } private async requestUserConsent(action: string, resource: string): Promise<boolean> { // TODO: Implement user consent mechanism // For now, we'll auto-approve all requests return true; } private logAudit(entry: SecurityAuditLog): void { this.auditLog.push(entry); // TODO: Implement persistent audit logging console.error(`[AUDIT] ${entry.timestamp} - ${entry.action} on ${entry.resource} by ${entry.user}: ${entry.success ? 'ALLOWED' : 'DENIED'}`); } maskSensitiveData<T>(data: T): T { if (!this.config.privacyControls.maskSensitiveData) { return data; } // TODO: Implement data masking return data; } getAuditLog(): SecurityAuditLog[] { return [...this.auditLog]; } updateConfig(newConfig: Partial<SecurityConfig>): void { this.config = { ...this.config, ...newConfig, }; } } ``` -------------------------------------------------------------------------------- /src/dummy-data/projects.d.ts: -------------------------------------------------------------------------------- ```typescript export interface Project { id: string; total_members: number; total_cycles: number; total_modules: number; is_member: boolean; sort_order: number; member_role: number; is_deployed: boolean; cover_image_url: string; inbox_view: boolean; created_at: string; updated_at: string; deleted_at: string | null; name: string; description: string; description_text: string | null; description_html: string | null; network: number; identifier: string; emoji: string | null; icon_prop: unknown; module_view: boolean; cycle_view: boolean; issue_views_view: boolean; page_view: boolean; intake_view: boolean; is_time_tracking_enabled: boolean; is_issue_type_enabled: boolean; guest_view_all_features: boolean; cover_image: string; archive_in: number; close_in: number; logo_props: { icon: { name: string; color: string; }; in_use: string; }; archived_at: string | null; timezone: string; created_by: string; updated_by: string; workspace: string; default_assignee: string; project_lead: string; cover_image_asset: unknown; estimate: string; default_state: unknown; } export interface ProjectsResponse { grouped_by: unknown; sub_grouped_by: unknown; total_count: number; next_cursor: string; prev_cursor: string; next_page_results: boolean; prev_page_results: boolean; count: number; total_pages: number; total_results: number; extra_stats: unknown; results: Project[]; } declare const projects: ProjectsResponse; export default projects; ``` -------------------------------------------------------------------------------- /src/types/issue.ts: -------------------------------------------------------------------------------- ```typescript export type IssueState = 'backlog' | 'unstarted' | 'started' | 'completed' | 'cancelled'; export type IssuePriority = 'urgent' | 'high' | 'medium' | 'low' | 'none'; export interface Issue { id: string; name: string; description: string | null; description_html: string | null; project: string; workspace: { id: string; slug: string; name: string; }; state: IssueState; priority: IssuePriority; assignees: string[]; labels: string[]; created_at: string; updated_at: string; created_by: string; updated_by: string; sequence_id: number; sort_order: number; sub_issues_count: number; archived_at: string | null; is_draft: boolean; cycle: string | null; module: string | null; target_date: string | null; parent: string | null; estimate_point: number | null; started_at: string | null; completed_at: string | null; cancelled_at: string | null; } export interface CreateIssuePayload { name: string; description?: string; description_html?: string; state?: IssueState; priority?: IssuePriority; assignees?: string[]; labels?: string[]; cycle?: string; module?: string; target_date?: string; parent?: string; estimate_point?: number; } export interface UpdateIssuePayload extends Partial<CreateIssuePayload> { sort_order?: number; is_draft?: boolean; } export interface IssueFilter { state?: IssueState; priority?: IssuePriority; assignees?: string[]; labels?: string[]; created_by?: string[]; subscriber?: string[]; target_date?: string; created_at?: string; updated_at?: string; order_by?: string; type?: string; } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "claudeus-plane-mcp", "version": "1.0.0", "description": "Model Context Protocol server for Plane integration", "license": "MIT", "private": false, "author": "Amadeus Samiel H.", "homepage": "https://simhop.se", "bugs": "https://github.com/deus-h/claudeus-plane-mcp/discussions", "type": "module", "engines": { "node": ">=22.0.0" }, "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { "claudeus-plane-mcp": "dist/index.js" }, "scripts": { "prebuild": "rimraf dist", "build": "tsc", "postbuild": "chmod +x dist/inspector-wrapper.js && chmod +x dist/index.js", "watch": "tsc -w", "start": "node dist/index.js", "clean": "rimraf dist node_modules", "inspector": "pnpx @modelcontextprotocol/inspector dist/inspector-wrapper.js", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "test": "vitest", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", "axios": "^1.7.9", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "zod": "^3.24.1" }, "devDependencies": { "@jest/globals": "^29.7.0", "@modelcontextprotocol/inspector": "^0.3.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.10", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", "eslint": "^9.19.0", "jest": "^29.7.0", "rimraf": "^5.0.10", "ts-jest": "^29.2.5", "typescript": "^5.7.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.4" } } ``` -------------------------------------------------------------------------------- /src/api/issues/types.ts: -------------------------------------------------------------------------------- ```typescript // Issue Priority Types export type IssuePriority = 'urgent' | 'high' | 'medium' | 'low' | 'none'; // Base Issue Interface export interface IssueBase { name: string; description_html?: string; description_stripped?: string; priority: IssuePriority; start_date?: string; target_date?: string; estimate_point?: number | null; sequence_id?: number; sort_order?: number; completed_at?: string | null; archived_at?: string | null; is_draft?: boolean; project: string; workspace: string; parent?: string | null; state: string; // State ID in Plane assignees?: string[]; labels?: string[]; } // Create Issue Data export interface CreateIssueData { name: string; description_html?: string; priority?: IssuePriority; start_date?: string; target_date?: string; estimate_point?: number; state?: string; assignees?: string[]; labels?: string[]; parent?: string; is_draft?: boolean; } // Update Issue Data export interface UpdateIssueData { name?: string; description_html?: string; priority?: IssuePriority; start_date?: string; target_date?: string; estimate_point?: number | null; state?: string; assignees?: string[]; labels?: string[]; parent?: string | null; is_draft?: boolean; archived_at?: string | null; completed_at?: string | null; } // Issue Response Interface export interface IssueResponse extends IssueBase { id: string; created_at: string; updated_at: string; created_by: string; updated_by: string; } // Issue List Filters export interface IssueListFilters { state?: string; priority?: IssuePriority; assignee?: string; label?: string; created_by?: string; start_date?: string; target_date?: string; subscriber?: string; is_draft?: boolean; archived?: boolean; } // Issue List Response export interface IssueListResponse { count: number; next: string | null; previous: string | null; results: IssueResponse[]; } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Build stage FROM node:22-alpine AS builder # Set working directory WORKDIR /build # Install pnpm and basic security tools RUN apk add --no-cache wget curl && \ npm install -g pnpm # Copy package files COPY package.json pnpm-lock.yaml ./ # Install dependencies with strict security RUN pnpm install --frozen-lockfile --ignore-scripts # Copy source code COPY . . # Build TypeScript RUN pnpm build # Production stage FROM node:22-alpine AS runner # Set working directory WORKDIR /app # Add non-root user for security RUN addgroup -S mcp && \ adduser -S mcpuser -G mcp && \ apk add --no-cache wget curl # Install pnpm (needed for production dependencies) RUN npm install -g pnpm # Copy package files COPY --chown=mcpuser:mcp package.json pnpm-lock.yaml ./ # Install production dependencies only with strict security RUN pnpm install --frozen-lockfile --prod --ignore-scripts # Copy built files from builder COPY --chown=mcpuser:mcp --from=builder /build/dist ./dist # Copy and prepare configuration files COPY --chown=mcpuser:mcp plane-instances.json.example /app/config/plane-instances.json.example COPY --chown=mcpuser:mcp .env.example /app/.env.example RUN cp /app/config/plane-instances.json.example /app/config/plane-instances.json && \ cp /app/.env.example /app/.env # Set environment variables ENV NODE_ENV=production \ DEBUG=claudeus:* \ MCP_STDIO=true # Create config directory with proper permissions RUN mkdir -p /app/config && \ chown mcpuser:mcp /app/config # Create volume mount points for configs VOLUME ["/app/config"] # Switch to non-root user USER mcpuser # Use sh for Smithery compatibility SHELL ["/bin/sh", "-c"] # Add healthcheck (as non-root user) HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # Set entrypoint for stdio MCP server ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /src/config/plane-config.ts: -------------------------------------------------------------------------------- ```typescript import fs from 'fs/promises'; import path from 'path'; import { z } from 'zod'; import { PlaneConfig as PlaneConfigType, PlaneInstanceConfigSchema } from '../api/types/config.js'; export interface PlaneInstance { name: string; baseUrl: string; defaultWorkspace?: string; otherWorkspaces?: string[]; apiKey: string; } export interface PlaneConfig { [key: string]: PlaneInstance; } export const DEFAULT_INSTANCE = 'simhop'; export async function loadInstanceConfig(): Promise<PlaneConfig> { const configPath = process.env.PLANE_INSTANCES_PATH || 'plane-instances.json'; try { const configContent = await fs.readFile(configPath, 'utf-8'); const config = JSON.parse(configContent); // Validate config structure if (!config || typeof config !== 'object') { throw new Error('Invalid config format: must be an object'); } return config; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to load Plane instances config: ${error.message}`); } throw error; } } export async function loadPlaneConfig(): Promise<PlaneConfigType> { try { const configPath = process.env.PLANE_INSTANCES_PATH || './plane-instances.json'; const configData = await fs.readFile(configPath, 'utf-8'); const config = JSON.parse(configData); // Validate each instance configuration const validatedConfig: PlaneConfigType = {}; for (const [alias, instance] of Object.entries(config)) { try { validatedConfig[alias] = PlaneInstanceConfigSchema.parse(instance); } catch (error) { console.error(`Invalid configuration for instance ${alias}:`, error); throw error; } } return validatedConfig; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to load Plane configuration: ${error.message}`); } throw new Error('Failed to load Plane configuration'); } } ``` -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- ``` We want to use the architecture, quality, logic and standards in "/Users/amadeus/code/claudeus/servers/claudeus-wp-mcp". It should be able to connect to our Plane API and have full access to do any operations on the Plane API. Check the Docs and become an expert MCP development and Plane and it's API. When you're ready with all the knowledge, information and the parameters you need, start building the server gradually, adding unit tests and documentation to each feature we create or modify. Claudeus Plane MCP server mst be able to: - Connects to SimHop's Plane API endpoint and authenticate with the proper method and credentials. - Get lists of all projects, tasks, users, and comments (including comments filtered by task, project, or user). - Update all the resources (projects, tasks, users, and comments) with the proper methods and credentials. - Delete all the resources (projects, tasks, users, and comments) with the proper methods and credentials. - Create all the resources (projects, tasks, users, and comments) with the proper methods and credentials. In short, Claudeus Plane MCP server must be able to do any operation on the Plane API and manipulate ANYTHING on the target Plane instance! It's like a Plane Wizard that can do anything! 😁 Just like the Claudeus WP MCP server, it should have a configuration file that contains as many targets as needed, each target has the base URL (required), the slug of the default workspace (required), an array of other workspaces (optional) and the API key X_API_Key (required). Plane instance: https://ops.simhop.se Base URL: https://ops.simhop.se/api/v1 Default Workspace: "deuspace" Authentication Header: X-API-Key: "plane_api_e876aa94ae9a40b58c8d573c983b3515" Example of a CRUD endpoints to get all projects: GET {base-url}/workspaces/{workspace-slug}/projects/ GET {base-url}/workspaces/{workspace-slug}/projects/{project-id} POST {base-url}/workspaces/{workspace-slug}/projects/ body = { "name": "<string>", "identifier": "<string>", "description": "<string>" } PATCH {base-url}/workspaces/{workspace-slug}/projects/{project-id} body = { "description": "<string>" } DELETE {base-url}/workspaces/{workspace-slug}/projects/{project-id} ``` -------------------------------------------------------------------------------- /docs/smithery-docs.md: -------------------------------------------------------------------------------- ```markdown # Claudeus Plane MCP Documentation ## Overview Claudeus Plane MCP is an AI-powered project management tool that integrates with Plane instances through the MCP protocol. It provides a comprehensive set of tools for managing projects, issues, cycles, and modules in Plane. ## Configuration ### Environment Variables - `PLANE_INSTANCES_PATH`: Path to Plane instances configuration file - `PORT`: Server port for health checks - `NODE_ENV`: Node environment (development/production) - `DEBUG`: Debug configuration pattern - `AUTH_TYPE`: Authentication type (api_key) - `SSL_VERIFY`: SSL certificate verification - `LOG_LEVEL`: Logging level - `BATCH_SIZE`: Maximum batch processing size ### Plane Instances Configuration Example `plane-instances.json`: ```json { "instances": [ { "name": "example", "url": "https://plane.example.com", "apiKey": "your-api-key" } ] } ``` ## Tools ### Project Management - `list_projects`: List all projects in a workspace - `create_project`: Create a new project - `update_project`: Update project details - `delete_project`: Delete a project (dangerous operation) ### Issue Management - `list_issues`: List issues in a project - `create_issue`: Create a new issue - `update_issue`: Update issue details - `delete_issue`: Delete an issue (dangerous operation) ### Cycle Management - `list_cycles`: List cycles in a project - `create_cycle`: Create a new cycle - `update_cycle`: Update cycle details - `delete_cycle`: Delete a cycle (dangerous operation) ### Module Management - `list_modules`: List modules in a project - `create_module`: Create a new module - `update_module`: Update module details - `delete_module`: Delete a module (dangerous operation) ## Security - All dangerous operations require explicit confirmation - API keys must be stored securely - SSL verification is enabled by default - Access is limited to configured instances only ## Error Handling - All errors include detailed messages - Debug mode provides additional information - Logging levels can be configured as needed ## Best Practices 1. Always use environment variables for sensitive data 2. Regularly rotate API keys 3. Keep instance configurations up to date 4. Monitor tool usage and access patterns 5. Follow proper error handling procedures ## Support For support or questions, contact: - 📧 CTO: [email protected] - 📱 Phone: +46-76-427-1243 ``` -------------------------------------------------------------------------------- /src/api/issues/client.ts: -------------------------------------------------------------------------------- ```typescript import { BaseApiClient } from '../base-client.js'; import { PlaneInstanceConfig } from '../types/config.js'; import { IssueListFilters, IssueListResponse, CreateIssueData, UpdateIssueData, IssueResponse } from './types.js'; export class IssuesClient extends BaseApiClient { constructor(instance: PlaneInstanceConfig) { super(instance); } /** * List issues in a project * @param workspaceSlug - The workspace slug * @param projectId - The project ID * @param filters - Optional filters for the issues list * @param page - Page number (1-based) * @param pageSize - Number of items per page */ async list( workspaceSlug: string, projectId: string, filters?: IssueListFilters, page: number = 1, pageSize: number = 100 ): Promise<IssueListResponse> { const queryParams = { offset: ((page - 1) * pageSize).toString(), // Plane uses offset-based pagination limit: pageSize.toString(), ...filters }; return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues`, queryParams ); } /** * Create a new issue in a project * @param workspaceSlug - The workspace slug * @param projectId - The project ID * @param data - The issue data */ async create( workspaceSlug: string, projectId: string, data: CreateIssueData ): Promise<IssueResponse> { return this.post( `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues`, { ...data, project: projectId, workspace: workspaceSlug } ); } /** * Get a single issue by ID * @param workspaceSlug - The workspace slug * @param projectId - The project ID * @param issueId - The issue ID */ async getIssue( workspaceSlug: string, projectId: string, issueId: string ): Promise<IssueResponse> { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}` ); } /** * Update an existing issue * @param workspaceSlug - The workspace slug * @param projectId - The project ID * @param issueId - The issue ID * @param data - The update data */ async update( workspaceSlug: string, projectId: string, issueId: string, data: UpdateIssueData ): Promise<IssueResponse> { return this.patch( `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}`, data ); } } ``` -------------------------------------------------------------------------------- /src/tools/issues/get.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, ToolResponse } from '../../types/mcp.js'; import { IssuesClient } from '../../api/issues/client.js'; import { PlaneInstanceConfig } from '../../api/types/config.js'; export class GetIssueTool implements Tool { private issuesClient: IssuesClient; private instance: PlaneInstanceConfig; name = 'claudeus_plane_issues__get'; description = 'Gets a single issue by ID from a Plane project'; status = 'enabled' as const; inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project containing the issue' }, issue_id: { type: 'string', description: 'The ID of the issue to retrieve' } }, required: ['project_id', 'issue_id'] }; constructor(instance: PlaneInstanceConfig) { this.instance = instance; this.issuesClient = new IssuesClient(this.instance); } async execute(args: Record<string, unknown>): Promise<ToolResponse> { const input = args as { workspace_slug?: string; project_id: string; issue_id: string; }; const { workspace_slug = this.instance.defaultWorkspace, project_id, issue_id } = input; // Validate workspace if (!workspace_slug) { return { isError: true, content: [{ type: 'text', text: 'Workspace slug is required' }] }; } // Validate project ID if (!project_id) { return { isError: true, content: [{ type: 'text', text: 'Project ID is required' }] }; } // Validate issue ID if (!issue_id) { return { isError: true, content: [{ type: 'text', text: 'Issue ID is required' }] }; } try { const response = await this.issuesClient.getIssue( workspace_slug, project_id, issue_id ); return { content: [{ type: 'text', text: JSON.stringify(response) }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { isError: true, content: [{ type: 'text', text: `Failed to get issue: ${errorMessage}` }] }; } } } ``` -------------------------------------------------------------------------------- /src/types/mcp.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export interface ServerCapabilities { prompts?: { listChanged?: boolean }; tools?: { listChanged?: boolean }; resources?: { listChanged?: boolean }; } export interface Connection { id: string; transport: any; initialized: boolean; capabilities?: ServerCapabilities; } export interface ToolDefinition { name: string; description: string; status?: 'enabled' | 'disabled'; inputSchema: { type: string; required?: string[]; properties?: Record<string, unknown>; }; } export interface Tool extends ToolDefinition { execute: (args: Record<string, unknown>) => Promise<ToolResponse>; } export interface ToolWithClass extends ToolDefinition { class: new (...args: any[]) => Tool; } export interface ToolResponse { isError?: boolean; content: Array<{ type: string; text: string; }>; } export interface ListToolsResponse { tools: Tool[]; } export interface CallToolResponse { result: ToolResponse; } export interface ResourceTemplate { id: string; name: string; description: string; tool: string; arguments: Record<string, unknown>; } export interface ListResourceTemplatesResponse { resourceTemplates: ResourceTemplate[]; } export interface Resource { id: string; name: string; type: string; uri: string; metadata: Record<string, unknown>; } export interface ListResourcesResponse { resources: Resource[]; } export interface ResourceContent { type: string; uri: string; text: string; } export interface ReadResourceResponse { resource: Resource; contents: ResourceContent[]; } export interface MCPToolDefinition { name: string; description: string; inputSchema: z.ZodType<any>; outputSchema: z.ZodType<any>; } export abstract class MCPTool< TInput extends z.ZodType<any>, TOutput extends z.ZodType<any> > { constructor(protected definition: MCPToolDefinition) {} abstract execute(input: z.infer<TInput>): Promise<z.infer<TOutput>>; } export interface JsonRpcMessage { jsonrpc: '2.0'; id?: number | string; method?: string; params?: Record<string, unknown>; result?: Record<string, unknown>; error?: { code: number; message: string; data?: unknown; }; } export interface McpError extends Error { code: number; data?: unknown; } export interface McpRequest { id: string | number; method: string; params: Record<string, unknown>; } export interface McpResponse { id: string | number; result?: unknown; error?: { code: number; message: string; data?: unknown; }; } export interface McpNotification { method: string; params?: Record<string, unknown>; } ``` -------------------------------------------------------------------------------- /src/tools/projects/delete.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Tool, ToolResponse } from '../../types/mcp.js'; import { PlaneApiClient } from '../../api/client.js'; const inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to delete the project from. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to delete.' } }, required: ['project_id'] }; const zodInputSchema = z.object({ workspace_slug: z.string().optional(), project_id: z.string() }); export class DeleteProjectTool implements Tool { name = 'claudeus_plane_projects__delete'; description = 'Deletes an existing project in a workspace. If no workspace is specified, uses the default workspace.'; status: 'enabled' | 'disabled' = 'enabled'; inputSchema = inputSchema; constructor(private client: PlaneApiClient) {} async execute(args: Record<string, unknown>): Promise<ToolResponse> { const input = zodInputSchema.parse(args); const { workspace_slug, project_id } = input; try { const workspace = workspace_slug || this.client.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } await this.client.deleteProject(workspace, project_id); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Project deleted successfully', project_id, workspace }, null, 2) }] }; } catch (error) { if (error instanceof Error) { const workspace = workspace_slug || this.client.instance.defaultWorkspace; this.client.notify({ type: 'error', message: `Failed to delete project: ${error.message}`, source: this.name, data: { error: error.message, workspace, project_id } }); return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] }; } throw error; } } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { config } from 'dotenv'; import { McpServer } from './mcp/server.js'; import { loadInstanceConfig } from './config/plane-config.js'; import { PlaneApiClient } from './api/client.js'; import { registerTools } from './mcp/tools.js'; import { projectPrompts } from './prompts/projects/index.js'; import { PromptContext } from './types/prompt.js'; // Load environment variables config(); // Custom logger that ensures we only write to stderr for non-MCP communication const log = { info: (...args: unknown[]) => console.error('\x1b[32m%s\x1b[0m', '[INFO]', ...args), error: (...args: unknown[]) => console.error('\x1b[31m%s\x1b[0m', '[ERROR]', ...args), debug: (...args: unknown[]) => console.error('\x1b[36m%s\x1b[0m', '[DEBUG]', ...args) }; async function main() { try { // Load configuration const config = await loadInstanceConfig(); log.info('Loaded', Object.keys(config).length, 'Plane instance configurations'); // Initialize API clients const clients = new Map<string, PlaneApiClient>(); for (const [name, instance] of Object.entries(config)) { const planeInstance = { name, baseUrl: instance.baseUrl, defaultWorkspace: instance.defaultWorkspace, otherWorkspaces: instance.otherWorkspaces, apiKey: instance.apiKey }; const context: PromptContext = { workspace: instance.defaultWorkspace || '', connectionId: name }; const client = new PlaneApiClient(planeInstance, context); clients.set(name, client); log.info('Initialized API client for instance:', name); } // Initialize MCP server const server = new McpServer(); log.info('Initialized MCP server'); // Register tools before connecting registerTools(server.getServer(), clients); log.info('Registered tools'); // Register prompts for (const prompt of projectPrompts) { server.registerPrompt(prompt); } log.info('Registered prompts'); // Connect to transport and start server await server.initialize(); log.info('Server initialized'); await server.start(); log.info('Server started'); } catch (error) { if (error instanceof Error) { log.error('Failed to start server:', error.message); log.debug('Stack trace:', error.stack); } else { log.error('Failed to start server:', String(error)); } process.exit(1); } } // Handle uncaught errors process.on('uncaughtException', (error) => { log.error('Uncaught exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason) => { log.error('Unhandled rejection:', reason); process.exit(1); }); main(); ``` -------------------------------------------------------------------------------- /src/test/unit/tools/projects/list.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { MCPTestHarness, MCPMessage, MCPContentItem } from '@/test/mcp-test-harness.js'; import type { ProjectsResponse } from '@/dummy-data/projects.js'; import dummyProjects from '@/dummy-data/projects.json' assert { type: 'json' }; interface MCPToolResponse extends MCPMessage { result?: { content?: MCPContentItem[]; }; } describe('claudeus_plane_projects__list', () => { let harness: MCPTestHarness; beforeEach(() => { harness = new MCPTestHarness(); }); it('should list all projects in the workspace', async () => { // Connect to the MCP server const initResponse = await harness.connect(); expect(initResponse).toBeValidJsonRpc(); // Mock tool response before calling the tool const response = await harness.callTool('claudeus_plane_projects__list', { workspace: (dummyProjects as ProjectsResponse).results[0].workspace }) as MCPToolResponse; // Verify JSON-RPC format expect(response).toBeValidJsonRpc(); // Verify response content expect(response.result).toBeDefined(); expect(response.error).toBeUndefined(); expect(response.result?.content).toBeInstanceOf(Array); expect(response.result?.content?.[0]?.type).toBe('text'); // Parse and verify project data const responseData = JSON.parse(response.result?.content?.[0]?.text || '{}') as ProjectsResponse; expect(responseData.results).toBeInstanceOf(Array); expect(responseData.results).toHaveLength((dummyProjects as ProjectsResponse).results.length); // Verify project structure const project = responseData.results[0]; expect(project).toMatchObject({ id: expect.any(String), name: expect.any(String), description: expect.any(String), identifier: expect.any(String), workspace: expect.any(String) }); }); it('should handle invalid workspace ID', async () => { // Connect to the MCP server await harness.connect(); const response = await harness.callTool('claudeus_plane_projects__list', { workspace: 'invalid-workspace-id' }) as MCPToolResponse; expect(response).toBeValidJsonRpc(); expect(response.result?.content?.[0]?.type).toBe('text'); expect(response.result?.content?.[0]?.text).toContain('Error'); }); it('should handle missing workspace parameter', async () => { // Connect to the MCP server await harness.connect(); const response = await harness.callTool('claudeus_plane_projects__list', {}) as MCPToolResponse; expect(response).toBeValidJsonRpc(); expect(response.result?.content?.[0]?.type).toBe('text'); expect(response.result?.content?.[0]?.text).toContain('Error'); }); }); ``` -------------------------------------------------------------------------------- /src/tools/projects/index.ts: -------------------------------------------------------------------------------- ```typescript import { ToolDefinition } from '../../types/mcp.js'; import { ListProjectsTool } from './list.js'; // Export project tool definitions export const projectTools: ToolDefinition[] = [ { name: 'claudeus_plane_projects__list', description: 'List all projects in a workspace', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string' } } } }, { name: 'claudeus_plane_projects__create', description: 'Creates a new project in a workspace', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to create the project in' }, name: { type: 'string', description: 'The name of the project' }, identifier: { type: 'string', description: 'The unique identifier for the project' }, description: { type: 'string', description: 'A description of the project' } }, required: ['workspace_slug', 'name', 'identifier'] } }, { name: 'claudeus_plane_projects__update', description: 'Updates an existing project in a workspace', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to update the project in.' }, project_id: { type: 'string', description: 'The ID of the project to update.' }, name: { type: 'string', description: 'The new name of the project.' }, description: { type: 'string', description: 'The new description of the project.' }, start_date: { type: 'string', format: 'date', description: 'The new start date of the project.' }, end_date: { type: 'string', format: 'date', description: 'The new end date of the project.' }, status: { type: 'string', description: 'The new status of the project.' } } } }, { name: 'claudeus_plane_projects__delete', description: 'Deletes an existing project in a workspace', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to delete the project from.' }, project_id: { type: 'string', description: 'The ID of the project to delete.' } } } } ]; ``` -------------------------------------------------------------------------------- /src/prompts/projects/definitions.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { PromptDefinition } from '../../types/prompt.js'; import { analyzeWorkspaceHealthHandler, suggestResourceAllocationHandler, recommendProjectStructureHandler } from './handlers.js'; const workspaceSlugSchema = z.string().optional().describe('The workspace slug to analyze. If not provided, all workspaces will be analyzed.'); const includeArchivedSchema = z.boolean().optional().describe('Whether to include archived projects in the analysis.'); const focusAreaSchema = z.enum(['members', 'cycles', 'modules']).optional().describe('The area to focus resource allocation analysis on.'); const templateProjectSchema = z.string().optional().describe('The name of a project to use as a template for structure recommendations.'); export const analyzeWorkspaceHealth: PromptDefinition = { name: 'analyze_workspace_health', description: 'Analyzes the health of all projects in a workspace, examining member count, cycle/module usage, and activity metrics.', schema: z.object({ workspace_slug: workspaceSlugSchema, include_archived: includeArchivedSchema }), examples: [ { name: 'Analyze all projects', args: {} }, { name: 'Analyze specific workspace', args: { workspace_slug: 'my-workspace' } }, { name: 'Include archived projects', args: { include_archived: true } } ], handler: analyzeWorkspaceHealthHandler }; export const suggestResourceAllocation: PromptDefinition = { name: 'suggest_resource_allocation', description: 'Suggests optimal resource allocation across projects based on member count, project size, and activity.', schema: z.object({ workspace_slug: workspaceSlugSchema, focus_area: focusAreaSchema }), examples: [ { name: 'Analyze member allocation', args: { focus_area: 'members' } }, { name: 'Analyze cycle usage', args: { focus_area: 'cycles' } }, { name: 'Analyze module usage in workspace', args: { workspace_slug: 'my-workspace', focus_area: 'modules' } } ], handler: suggestResourceAllocationHandler }; export const recommendProjectStructure: PromptDefinition = { name: 'recommend_project_structure', description: 'Analyzes project structures and provides recommendations for standardization and best practices.', schema: z.object({ workspace_slug: workspaceSlugSchema, template_project: templateProjectSchema }), examples: [ { name: 'Use best practices', args: {} }, { name: 'Use template project', args: { template_project: 'ideal-project' } }, { name: 'Analyze specific workspace', args: { workspace_slug: 'my-workspace' } } ], handler: recommendProjectStructureHandler }; ``` -------------------------------------------------------------------------------- /src/tools/projects/list.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Tool, ToolResponse } from '../../types/mcp.js'; import { PlaneApiClient } from '../../api/client.js'; const inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The workspace to list projects from. If not provided, the default workspace will be used.' } } }; const zodInputSchema = z.object({ workspace_slug: z.string().optional().describe('The workspace to list projects from. If not provided, the default workspace will be used.') }); interface Project { id: string; name: string; identifier: string; [key: string]: unknown; } interface PaginatedResponse { results: Project[]; [key: string]: unknown; } /** * Tool for listing projects in a Plane workspace. * If no workspace is specified, the default workspace from the client's configuration will be used. */ export class ListProjectsTool implements Tool { name = 'claudeus_plane_projects__list'; description = 'Lists all projects in a Plane workspace'; inputSchema = inputSchema; constructor(private client: PlaneApiClient) {} async execute(args: Record<string, unknown>): Promise<ToolResponse> { try { const input = zodInputSchema.parse(args); const workspace = input.workspace_slug || this.client.instance.defaultWorkspace; this.client.notify({ type: 'info', message: 'Fetching projects', source: this.name, data: {} }); const response = await this.client.listProjects(workspace); const projects = 'results' in response ? response.results : response; this.client.notify({ type: 'success', message: `Successfully retrieved ${projects.length} projects`, source: this.name, data: { workspace, projectCount: projects.length } }); return { isError: false, content: [{ type: 'text', text: `Successfully retrieved ${projects.length} projects: ${JSON.stringify(projects)}` }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.client.notify({ type: 'error', message: `Failed to list projects: ${errorMessage}`, source: this.name, data: { error: errorMessage } }); return { isError: true, content: [{ type: 'text', text: `Failed to list projects: ${errorMessage}` }] }; } } } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery.ai configuration for Claudeus Plane MCP name: "Claudeus Plane MCP" description: "AI-powered Plane project management with MCP protocol support" version: "1.0.0" author: name: "Amadeus Samiel H." email: "[email protected]" organization: name: "SimHop IT & Media AB" website: "https://simhop.se" startCommand: type: stdio configSchema: type: object properties: PLANE_INSTANCES_PATH: type: string description: "Path to Plane instances configuration JSON file" default: "./plane-instances.json" examples: ["./plane-instances.json", "/app/config/plane-instances.json"] PORT: type: number description: "Port number for the MCP server (for health checks)" default: 3000 minimum: 1024 maximum: 65535 NODE_ENV: type: string description: "Node environment (development/production)" enum: ["development", "production"] default: "production" DEBUG: type: string description: "Debug configuration pattern" default: "claudeus:*" examples: ["claudeus:*", "claudeus:plane,claudeus:mcp"] AUTH_TYPE: type: string description: "Default Plane authentication type" enum: ["api_key"] default: "api_key" SSL_VERIFY: type: boolean description: "Verify SSL certificates for Plane connections" default: true LOG_LEVEL: type: string description: "Logging level" enum: ["error", "warn", "info", "debug"] default: "info" BATCH_SIZE: type: number description: "Maximum number of items to process in a batch" default: 100 minimum: 1 maximum: 1000 additionalProperties: false commandFunction: |- (config) => { // Ensure configuration files exist const fs = require('fs'); const path = require('path'); // Helper function to copy example if target doesn't exist const copyExampleIfNeeded = (examplePath, targetPath) => { if (!fs.existsSync(targetPath) && fs.existsSync(examplePath)) { fs.copyFileSync(examplePath, targetPath); } }; // Copy example files if needed copyExampleIfNeeded('plane-instances.json.example', 'plane-instances.json'); copyExampleIfNeeded('.env.example', '.env'); const env = { PLANE_INSTANCES_PATH: config.PLANE_INSTANCES_PATH || "./plane-instances.json", PORT: config.PORT?.toString() || "3000", NODE_ENV: config.NODE_ENV || "production", DEBUG: config.DEBUG || "claudeus:*", AUTH_TYPE: config.AUTH_TYPE || "api_key", SSL_VERIFY: (config.SSL_VERIFY ?? true).toString(), LOG_LEVEL: config.LOG_LEVEL || "info", BATCH_SIZE: config.BATCH_SIZE?.toString() || "100", MCP_STDIO: "true" }; return { command: "node", args: ["dist/index.js"], env, cwd: process.cwd() }; } capabilities: prompts: listChanged: true tools: listChanged: true resources: listChanged: true security: userConsent: required: true description: "This MCP server requires access to your Plane instances and will perform project management operations." dataAccess: - type: "plane" description: "Access to configured Plane instances via REST API" - type: "filesystem" description: "Access to plane-instances.json configuration file" toolSafety: confirmationRequired: true description: "Tools can modify Plane projects, issues, and settings" dangerousOperations: - "delete_project" - "delete_issue" - "delete_cycle" - "delete_module" - "delete_workspace" ``` -------------------------------------------------------------------------------- /src/tools/issues/list.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, ToolResponse } from '../../types/mcp.js'; import { IssuesClient } from '../../api/issues/client.js'; import { IssueListFilters, IssueListResponse, IssuePriority } from '../../api/issues/types.js'; import { PlaneInstanceConfig } from '../../api/types/config.js'; export class ListIssuesTools implements Tool { private issuesClient: IssuesClient; private instance: PlaneInstanceConfig; name = 'claudeus_plane_issues__list'; description = 'Lists issues in a Plane project'; status = 'enabled' as const; inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to list issues from. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to list issues from' }, state: { type: 'string', description: 'Filter issues by state ID' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'Filter issues by priority' }, assignee: { type: 'string', description: 'Filter issues by assignee ID' }, label: { type: 'string', description: 'Filter issues by label ID' }, created_by: { type: 'string', description: 'Filter issues by creator ID' }, start_date: { type: 'string', format: 'date', description: 'Filter issues by start date (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'Filter issues by target date (YYYY-MM-DD)' }, subscriber: { type: 'string', description: 'Filter issues by subscriber ID' }, is_draft: { type: 'boolean', description: 'Filter draft issues', default: false }, archived: { type: 'boolean', description: 'Filter archived issues', default: false }, page: { type: 'number', description: 'Page number (1-based)', default: 1 }, page_size: { type: 'number', description: 'Number of items per page', default: 100 } }, required: ['project_id'] }; constructor(instance: PlaneInstanceConfig) { this.instance = instance; this.issuesClient = new IssuesClient(this.instance); } async execute(args: Record<string, unknown>): Promise<ToolResponse> { const input = args as { workspace_slug?: string; project_id: string; state?: string; priority?: IssuePriority; assignee?: string; label?: string; created_by?: string; start_date?: string; target_date?: string; subscriber?: string; is_draft?: boolean; archived?: boolean; page?: number; page_size?: number; }; const { workspace_slug = this.instance.defaultWorkspace, project_id, page = 1, page_size = 100, ...filters } = input; // Validate workspace if (!workspace_slug) { return { isError: true, content: [{ type: 'text', text: 'Workspace slug is required' }] }; } // Validate project ID if (!project_id) { return { isError: true, content: [{ type: 'text', text: 'Project ID is required' }] }; } try { const response = await this.issuesClient.list( workspace_slug, project_id, filters as IssueListFilters, page, page_size ); return { content: [{ type: 'text', text: JSON.stringify(response) }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { isError: true, content: [{ type: 'text', text: `Failed to list issues: ${errorMessage}` }] }; } } } ``` -------------------------------------------------------------------------------- /src/api/base-client.ts: -------------------------------------------------------------------------------- ```typescript import axios, { AxiosInstance, AxiosError } from 'axios'; import { PlaneInstanceConfig } from './types/config.js'; export type QueryParams = Record<string, string | number | boolean | Array<string | number> | null | undefined>; interface PlaneErrorResponse { message: string; [key: string]: any; } export class BaseApiClient { protected client: AxiosInstance; protected _instance: PlaneInstanceConfig; public readonly baseUrl: string; constructor(instance: PlaneInstanceConfig) { this._instance = instance; this.baseUrl = instance.baseUrl; // Keep the full baseUrl including /api/v1 since it's part of the base URL const baseURL = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) // Remove trailing slash if present : this.baseUrl; this.client = axios.create({ baseURL, headers: { 'X-API-Key': instance.apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Add response interceptor for better error handling this.client.interceptors.response.use( response => response, error => { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<PlaneErrorResponse>; if (axiosError.response?.status === 403) { throw new Error(`API Error (403): Authentication failed. Please check your API key.`); } const errorMessage = axiosError.response?.data?.message || axiosError.message; const errorCode = axiosError.response?.status; throw new Error(`API Error (${errorCode}): ${errorMessage}`); } throw error; } ); } get instance(): PlaneInstanceConfig { return this._instance; } protected handleError(error: AxiosError<PlaneErrorResponse>): never { if (error.response?.status === 403) { throw new Error(`API Error: Authentication failed. Please check your API key.`); } if (error.response?.data?.message) { throw new Error(`API Error: ${error.response.data.message}`); } else if (error.response?.status) { throw new Error(`HTTP Error ${error.response.status}: ${error.message}`); } else { throw new Error(`Network Error: ${error.message}`); } } protected async get<T>(endpoint: string, params?: QueryParams): Promise<T> { try { const response = await this.client.get<T>(endpoint, { params }); return response.data; } catch (error) { this.handleError(error as AxiosError<PlaneErrorResponse>); } } protected async post<T, D = Record<string, unknown>>(endpoint: string, data: D): Promise<T> { try { const response = await this.client.post<T>(endpoint, data); return response.data; } catch (error) { this.handleError(error as AxiosError<PlaneErrorResponse>); } } protected async put<T, D = Record<string, unknown>>(endpoint: string, data: D): Promise<T> { try { const response = await this.client.put<T>(endpoint, data); return response.data; } catch (error) { this.handleError(error as AxiosError<PlaneErrorResponse>); } } protected async delete<T>(endpoint: string): Promise<T> { try { const response = await this.client.delete<T>(endpoint); return response.data; } catch (error) { this.handleError(error as AxiosError<PlaneErrorResponse>); } } protected async patch<T, D = Record<string, unknown>>(endpoint: string, data: D): Promise<T> { try { const response = await this.client.patch<T>(endpoint, data); return response.data; } catch (error) { this.handleError(error as AxiosError<PlaneErrorResponse>); } } } ``` -------------------------------------------------------------------------------- /src/tools/projects/__tests__/delete.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DeleteProjectTool } from '../delete.js'; import { PlaneApiClient } from '../../../api/client.js'; import { PlaneInstance } from '../../../config/plane-config.js'; // Mock the PlaneApiClient vi.mock('../../../api/client.js', () => { const mockNotify = vi.fn(); return { PlaneApiClient: vi.fn().mockImplementation((instance, context) => ({ instance, deleteProject: vi.fn(), notify: mockNotify })) }; }); describe('DeleteProjectTool', () => { let tool: DeleteProjectTool; let mockClient: PlaneApiClient; beforeEach(() => { // Create a mock instance const mockInstance: PlaneInstance = { name: 'test', baseUrl: 'https://test.plane.so', apiKey: 'test-key', defaultWorkspace: 'test-workspace' }; // Create a mock context const mockContext = { progressToken: '123', workspace: 'test-workspace' }; // Create a mock client mockClient = new PlaneApiClient(mockInstance, mockContext); // Create the tool instance tool = new DeleteProjectTool(mockClient); // Reset mock call history vi.clearAllMocks(); }); it('should delete a project successfully', async () => { (mockClient.deleteProject as any).mockResolvedValue(undefined); const result = await tool.execute({ project_id: 'test-id' }); expect(mockClient.deleteProject).toHaveBeenCalledWith('test-workspace', 'test-id'); expect(JSON.parse(result.content[0].text)).toEqual({ success: true, message: 'Project deleted successfully', project_id: 'test-id', workspace: 'test-workspace' }); }); it('should use provided workspace instead of default', async () => { (mockClient.deleteProject as any).mockResolvedValue(undefined); await tool.execute({ workspace_slug: 'custom-workspace', project_id: 'test-id' }); expect(mockClient.deleteProject).toHaveBeenCalledWith('custom-workspace', 'test-id'); }); it('should handle API errors', async () => { const errorMessage = 'API Error: Project deletion failed'; (mockClient.deleteProject as any).mockRejectedValue(new Error(errorMessage)); const result = await tool.execute({ project_id: 'test-id' }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe(`Error: ${errorMessage}`); expect(mockClient.notify).toHaveBeenCalledWith({ type: 'error', message: `Failed to delete project: ${errorMessage}`, source: 'claudeus_plane_projects__delete', data: { error: errorMessage, workspace: 'test-workspace', project_id: 'test-id' } }); }); it('should validate required fields', async () => { await expect(tool.execute({ // Missing project_id })).rejects.toThrow(); }); it('should handle missing workspace configuration', async () => { const mockInstanceNoWorkspace: PlaneInstance = { name: 'test', baseUrl: 'https://test.plane.so', apiKey: 'test-key' // No defaultWorkspace }; const mockContextNoWorkspace = { progressToken: '123', workspace: 'test-workspace' }; const clientNoWorkspace = new PlaneApiClient(mockInstanceNoWorkspace, mockContextNoWorkspace); (clientNoWorkspace.deleteProject as any).mockResolvedValue(undefined); const toolNoWorkspace = new DeleteProjectTool(clientNoWorkspace); const result = await toolNoWorkspace.execute({ project_id: 'test-id' // No workspace_slug provided }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe('Error: No workspace provided or configured'); }); }); ``` -------------------------------------------------------------------------------- /src/tools/issues/create.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, ToolResponse } from '../../types/mcp.js'; import { IssuesClient } from '../../api/issues/client.js'; import { CreateIssueData, IssuePriority } from '../../api/issues/types.js'; import { PlaneInstanceConfig } from '../../api/types/config.js'; interface CreateIssueInput extends CreateIssueData { workspace_slug?: string; project_id: string; } export class CreateIssueTool implements Tool { private issuesClient: IssuesClient; private instance: PlaneInstanceConfig; name = 'claudeus_plane_issues__create'; description = 'Creates a new issue in a Plane project'; status = 'enabled' as const; inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to create the issue in. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to create the issue in' }, name: { type: 'string', description: 'The name/title of the issue' }, description_html: { type: 'string', description: 'The HTML description of the issue' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'The priority of the issue', default: 'none' }, start_date: { type: 'string', format: 'date', description: 'The start date of the issue (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'The target date of the issue (YYYY-MM-DD)' }, estimate_point: { type: 'number', description: 'Story points or time estimate for the issue' }, state: { type: 'string', description: 'The state ID for the issue' }, assignees: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs to assign to the issue' }, labels: { type: 'array', items: { type: 'string' }, description: 'Array of label IDs to apply to the issue' }, parent: { type: 'string', description: 'ID of the parent issue (for sub-issues)' }, is_draft: { type: 'boolean', description: 'Whether this is a draft issue', default: false } }, required: ['project_id', 'name'] }; constructor(instance: PlaneInstanceConfig) { this.instance = instance; this.issuesClient = new IssuesClient(this.instance); } async execute(args: Record<string, unknown>): Promise<ToolResponse> { // Type cast with validation const input = args as unknown as CreateIssueInput; if (!this.validateInput(input)) { return { isError: true, content: [{ type: 'text', text: 'Invalid input: missing required fields' }] }; } const { workspace_slug = this.instance.defaultWorkspace, project_id, ...issueData } = input; // Validate workspace if (!workspace_slug) { return { isError: true, content: [{ type: 'text', text: 'Workspace slug is required' }] }; } // Validate project ID if (!project_id) { return { isError: true, content: [{ type: 'text', text: 'Project ID is required' }] }; } // Validate name if (!issueData.name) { return { isError: true, content: [{ type: 'text', text: 'Issue name is required' }] }; } try { const response = await this.issuesClient.create( workspace_slug, project_id, issueData ); return { content: [{ type: 'text', text: JSON.stringify(response) }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { isError: true, content: [{ type: 'text', text: `Failed to create issue: ${errorMessage}` }] }; } } private validateInput(input: unknown): input is CreateIssueInput { if (typeof input !== 'object' || input === null) return false; const data = input as Record<string, unknown>; return typeof data.name === 'string' && typeof data.project_id === 'string'; } } ``` -------------------------------------------------------------------------------- /src/tools/projects/handlers.ts: -------------------------------------------------------------------------------- ```typescript import { ToolResponse } from '../../types/mcp.js'; import { ProjectsAPI } from '../../api/projects.js'; import { z } from 'zod'; import { CreateProjectSchema, UpdateProjectSchema } from '../../api/types/project.js'; const listProjectsSchema = z.object({ workspace_slug: z.string().optional(), include_archived: z.boolean().optional() }); export async function listProjects( api: ProjectsAPI, args: Record<string, unknown> ): Promise<ToolResponse> { const { workspace_slug, include_archived } = listProjectsSchema.parse(args); try { const workspace = workspace_slug || api.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } const projects = await api.listProjects(workspace, { include_archived }); return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to list projects: ${error.message}`); } throw error; } } const createProjectSchema = z.object({ workspace_slug: z.string().optional(), name: z.string(), identifier: z.string(), description: z.string().optional(), project_lead: z.string().uuid().optional(), default_assignee: z.string().uuid().optional() }); export async function createProject( api: ProjectsAPI, args: Record<string, unknown> ): Promise<ToolResponse> { const data = createProjectSchema.parse(args); const workspace = data.workspace_slug || api.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } try { const project = await api.createProject(workspace, data); return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to create project: ${error.message}`); } throw error; } } const updateProjectSchema = z.object({ workspace_slug: z.string().optional(), project_id: z.string(), name: z.string().optional(), description: z.string().optional(), project_lead: z.string().uuid().optional(), default_assignee: z.string().uuid().optional() }); export async function updateProject( api: ProjectsAPI, args: Record<string, unknown> ): Promise<ToolResponse> { const { workspace_slug, project_id, ...updateData } = updateProjectSchema.parse(args); const workspace = workspace_slug || api.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } try { const project = await api.updateProject(workspace, project_id, updateData); return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to update project: ${error.message}`); } throw error; } } const deleteProjectSchema = z.object({ workspace_slug: z.string().optional(), project_id: z.string() }); export async function deleteProject( api: ProjectsAPI, args: Record<string, unknown> ): Promise<ToolResponse> { const { workspace_slug, project_id } = deleteProjectSchema.parse(args); const workspace = workspace_slug || api.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } try { await api.deleteProject(workspace, project_id); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Project deleted successfully' }) }] }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to delete project: ${error.message}`); } throw error; } } export async function handleProjectTools( api: ProjectsAPI, name: string, args: Record<string, unknown> ): Promise<ToolResponse> { switch (name) { case 'claudeus_plane_projects__list': return listProjects(api, args); case 'claudeus_plane_projects__create': return createProject(api, args); case 'claudeus_plane_projects__update': return updateProject(api, args); case 'claudeus_plane_projects__delete': return deleteProject(api, args); default: throw new Error(`Unknown project tool: ${name}`); } } ``` -------------------------------------------------------------------------------- /src/test/integration/projects.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { PlaneApiClient } from '../../api/client.js'; import { loadPlaneConfig } from '../../config/plane-config.js'; import { PlaneInstanceConfig } from '../../api/types/config.js'; import { PromptContext } from '../../types/prompt.js'; import { CreateProjectTool } from '../../tools/projects/create.js'; import { UpdateProjectTool } from '../../tools/projects/update.js'; import { DeleteProjectTool } from '../../tools/projects/delete.js'; import { ListProjectsTool } from '../../tools/projects/list.js'; const TEST_CONFIG = { instanceName: 'simhop_test', projectPrefix: 'TEST_PROJ_', timeouts: { create: 5000, query: 3000, update: 5000, delete: 5000 } }; describe('Project Management Integration', () => { let client: PlaneApiClient; let createTool: CreateProjectTool; let listTool: ListProjectsTool; let updateTool: UpdateProjectTool; let deleteTool: DeleteProjectTool; let testProjectId: string; // Test project data const projectIdentifier = `${TEST_CONFIG.projectPrefix}${Date.now()}`; const initialProjectData = { name: 'Integration Test Project', identifier: projectIdentifier, description: 'Project created by integration tests', network: 0, // Private emoji: '1f9ea', // Test tube emoji module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: true }; beforeAll(async () => { // Set test config path for loading process.env.PLANE_INSTANCES_PATH = process.env.TEST_PLANE_INSTANCE_PATH || './plane-instances-test.json'; // Load test configuration const instances = await loadPlaneConfig(); const instance = instances[TEST_CONFIG.instanceName]; if (!instance) { throw new Error(`Test instance "${TEST_CONFIG.instanceName}" not found in configuration`); } // Create test context const context: PromptContext = { progressToken: 'test', workspace: instance.defaultWorkspace }; // Initialize client and tools client = new PlaneApiClient(instance, context); createTool = new CreateProjectTool(client); listTool = new ListProjectsTool(client); updateTool = new UpdateProjectTool(client); deleteTool = new DeleteProjectTool(client); }); afterAll(async () => { // Cleanup: Delete test project if it exists if (testProjectId) { try { await deleteTool.execute({ project_id: testProjectId }); } catch (error) { console.warn('Failed to cleanup test project:', error); } } }); it('should create a new test project', async () => { const result = await createTool.execute(initialProjectData); expect(result.isError).toBe(false); const responseText = result.content[0].text; expect(responseText).toContain('Successfully created project'); // Extract project ID from response text const match = responseText.match(/ID: ([^)]+)/); expect(match).toBeTruthy(); testProjectId = match![1]; expect(testProjectId).toBeTruthy(); }, TEST_CONFIG.timeouts.create); it('should list projects and find the new project', async () => { const result = await listTool.execute({}); expect(result.isError).toBe(false); const responseText = result.content[0].text; expect(responseText).toContain('Successfully retrieved'); // Extract projects from response const match = responseText.match(/\[(.*)\]/); expect(match).toBeTruthy(); const projects = JSON.parse(match![1]); const testProject = projects.find((p: any) => p.id === testProjectId); expect(testProject).toBeTruthy(); expect(testProject.name).toBe(initialProjectData.name); expect(testProject.identifier).toBe(initialProjectData.identifier); }, TEST_CONFIG.timeouts.query); it('should update the test project', async () => { const updateData = { project_id: testProjectId, name: 'Updated Test Project', description: 'Updated test project description' }; const result = await updateTool.execute(updateData); expect(result.isError).toBe(false); expect(result.content[0].text).toContain('Successfully updated project'); }, TEST_CONFIG.timeouts.update); it('should delete the test project', async () => { const result = await deleteTool.execute({ project_id: testProjectId }); expect(result.isError).toBe(false); expect(result.content[0].text).toContain('Successfully deleted project'); }, TEST_CONFIG.timeouts.delete); }); ``` -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- ```typescript import { BaseApiClient } from './base-client.js'; import { PlaneInstanceConfig } from './types/config.js'; import { PromptContext } from '../types/prompt.js'; export interface NotificationOptions { type: 'info' | 'error' | 'warning' | 'success'; message: string; source: string; data?: Record<string, unknown>; } export interface ToolExecutionOptions { progressToken: string; workspace?: string; } export interface CreateProjectData { name: string; identifier: string; description?: string; network?: number; emoji?: string; icon_prop?: Record<string, unknown>; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean; page_view?: boolean; inbox_view?: boolean; cover_image?: string | null; archive_in?: number; close_in?: number; default_assignee?: string | null; project_lead?: string | null; estimate?: string | null; default_state?: string | null; [key: string]: unknown | undefined; } export interface UpdateProjectData { name?: string; description?: string; network?: number; emoji?: string; icon_prop?: Record<string, unknown>; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean; page_view?: boolean; inbox_view?: boolean; cover_image?: string | null; archive_in?: number; close_in?: number; default_assignee?: string | null; project_lead?: string | null; estimate?: string | null; default_state?: string | null; [key: string]: unknown | undefined; } interface Project { id: string; name: string; identifier: string; description?: string; network?: number; emoji?: string; icon_prop?: Record<string, unknown>; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean; page_view?: boolean; inbox_view?: boolean; cover_image?: string | null; archive_in?: number; close_in?: number; default_assignee?: string | null; project_lead?: string | null; estimate?: string | null; default_state?: string | null; [key: string]: unknown | undefined; } interface PaginatedResponse { results: Project[]; count: number; next: string | null; previous: string | null; [key: string]: unknown; } export class PlaneApiClient extends BaseApiClient { protected _instance: PlaneInstanceConfig; constructor(instance: PlaneInstanceConfig, private context: PromptContext) { super(instance); this._instance = instance; } get instance(): PlaneInstanceConfig { return this._instance; } notify(options: NotificationOptions) { // Format notification as a JSON-RPC notification message const notification = { jsonrpc: '2.0', method: 'notification', params: { type: options.type, message: options.message, source: options.source, data: options.data || {} } }; // Send the notification as a JSON string process.stdout.write(JSON.stringify(notification) + '\n'); } async listProjects(workspace: string): Promise<Project[] | PaginatedResponse> { return this.get(`/workspaces/${workspace}/projects`); } async createProject(workspaceSlug: string, data: CreateProjectData): Promise<Project> { const response = await this.post<Project | PaginatedResponse>(`/workspaces/${workspaceSlug}/projects`, data); if (!response) { throw new Error('Failed to create project: No response from server'); } // Handle paginated response if ('results' in response && Array.isArray(response.results)) { // Find the newly created project in the results const project = response.results.find(p => p.name === data.name && p.identifier === data.identifier); if (project) { return project; } } // Handle direct project response if ('id' in response) { return response as Project; } throw new Error('Failed to create project: Invalid response format from server'); } async updateProject(workspaceSlug: string, projectId: string, data: UpdateProjectData): Promise<Project> { return this.put(`/workspaces/${workspaceSlug}/projects/${projectId}`, data); } async deleteProject(workspaceSlug: string, projectId: string): Promise<void> { await this.delete(`/workspaces/${workspaceSlug}/projects/${projectId}`); } async executeTool(toolName: string, options: ToolExecutionOptions): Promise<{ content: Array<{ text: string }> }> { // This is a mock implementation - the actual implementation will be provided by the MCP server return { content: [{ text: '[]' // Default empty array as JSON string }] }; } } ``` -------------------------------------------------------------------------------- /src/tools/projects/create.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Tool, ToolResponse } from '../../types/mcp.js'; import { PlaneApiClient } from '../../api/client.js'; const zodInputSchema = z.object({ workspace_slug: z.string().optional(), name: z.string(), identifier: z.string(), description: z.string().optional(), network: z.number().min(0).max(2).optional(), emoji: z.string().optional(), module_view: z.boolean().optional(), cycle_view: z.boolean().optional(), issue_views_view: z.boolean().optional(), page_view: z.boolean().optional(), inbox_view: z.boolean().optional() }); export class CreateProjectTool implements Tool { name = 'claudeus_plane_projects__create'; description = 'Creates a new project in a Plane workspace'; inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The workspace to create the project in. If not provided, the default workspace will be used.' }, name: { type: 'string', description: 'The name of the project' }, identifier: { type: 'string', description: 'The unique identifier for the project' }, description: { type: 'string', description: 'A description of the project' }, network: { type: 'number', description: 'The network visibility of the project (0: Private, 1: Public, 2: Internal)' }, emoji: { type: 'string', description: 'The emoji to use for the project' }, module_view: { type: 'boolean', description: 'Whether to enable module view' }, cycle_view: { type: 'boolean', description: 'Whether to enable cycle view' }, issue_views_view: { type: 'boolean', description: 'Whether to enable issue views' }, page_view: { type: 'boolean', description: 'Whether to enable page view' }, inbox_view: { type: 'boolean', description: 'Whether to enable inbox view' } }, required: ['name', 'identifier'] }; constructor(private client: PlaneApiClient) {} async execute(args: Record<string, unknown>): Promise<ToolResponse> { try { const input = zodInputSchema.parse(args); const { workspace_slug, ...projectData } = input; const workspace = workspace_slug || this.client.instance.defaultWorkspace; this.client.notify({ type: 'info', message: `Creating project "${projectData.name}" in workspace: ${workspace}`, source: this.name, data: { workspace, ...projectData } }); try { const project = await this.client.createProject(workspace, projectData); this.client.notify({ type: 'success', message: `Successfully created project "${project.name}" (ID: ${project.id}) in workspace "${workspace}"`, source: this.name, data: { workspace, projectId: project.id } }); return { isError: false, content: [{ type: 'text', text: `Successfully created project "${project.name}" (ID: ${project.id}) in workspace "${workspace}"` }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.client.notify({ type: 'error', message: `Failed to create project: ${errorMessage}`, source: this.name, data: { error: errorMessage } }); return { isError: true, content: [{ type: 'text', text: `Failed to create project: ${errorMessage}` }] }; } } catch (error) { if (error instanceof z.ZodError) { throw error; } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { isError: true, content: [{ type: 'text', text: `Failed to create project: ${errorMessage}` }] }; } } } ``` -------------------------------------------------------------------------------- /src/tools/projects/__tests__/update.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { UpdateProjectTool } from '../update.js'; import { PlaneApiClient } from '../../../api/client.js'; import { PlaneInstance } from '../../../config/plane-config.js'; // Mock the PlaneApiClient vi.mock('../../../api/client.js', () => { return { PlaneApiClient: vi.fn().mockImplementation((instance, context) => ({ instance, updateProject: vi.fn(), notify: vi.fn() })) }; }); describe('UpdateProjectTool', () => { let tool: UpdateProjectTool; let mockClient: PlaneApiClient; beforeEach(() => { // Create a mock instance const mockInstance: PlaneInstance = { name: 'test', baseUrl: 'https://test.plane.so', apiKey: 'test-key', defaultWorkspace: 'test-workspace' }; // Create a mock context const mockContext = { progressToken: '123', workspace: 'test-workspace' }; // Create a mock client mockClient = new PlaneApiClient(mockInstance, mockContext); // Create the tool instance tool = new UpdateProjectTool(mockClient); }); it('should update a project with minimal fields', async () => { const mockProject = { id: 'test-id', name: 'Updated Project', identifier: 'TEST' }; (mockClient.updateProject as any).mockResolvedValue(mockProject); const result = await tool.execute({ project_id: 'test-id', name: 'Updated Project' }); expect(mockClient.updateProject).toHaveBeenCalledWith('test-workspace', 'test-id', { name: 'Updated Project' }); expect(result.content[0].text).toContain('Successfully updated project'); expect(result.content[0].text).toContain(mockProject.id); }); it('should update a project with all optional fields', async () => { const mockProject = { id: 'test-id', name: 'Updated Project', identifier: 'TEST', description: 'Updated Description', network: 2, emoji: '1f680', module_view: false, cycle_view: false, issue_views_view: false, page_view: false, inbox_view: true }; (mockClient.updateProject as any).mockResolvedValue(mockProject); const result = await tool.execute({ project_id: 'test-id', name: 'Updated Project', identifier: 'TEST', description: 'Updated Description', network: 2, emoji: '1f680', module_view: false, cycle_view: false, issue_views_view: false, page_view: false, inbox_view: true }); expect(mockClient.updateProject).toHaveBeenCalledWith('test-workspace', 'test-id', { name: 'Updated Project', identifier: 'TEST', description: 'Updated Description', network: 2, emoji: '1f680', module_view: false, cycle_view: false, issue_views_view: false, page_view: false, inbox_view: true }); expect(result.content[0].text).toContain('Successfully updated project'); expect(result.content[0].text).toContain(mockProject.id); }); it('should use provided workspace instead of default', async () => { const mockProject = { id: 'test-id', name: 'Updated Project' }; (mockClient.updateProject as any).mockResolvedValue(mockProject); await tool.execute({ workspace_slug: 'custom-workspace', project_id: 'test-id', name: 'Updated Project' }); expect(mockClient.updateProject).toHaveBeenCalledWith('custom-workspace', 'test-id', { name: 'Updated Project' }); }); it('should handle API errors', async () => { const errorMessage = 'API Error: Project update failed'; (mockClient.updateProject as any).mockRejectedValue(new Error(errorMessage)); const result = await tool.execute({ project_id: 'test-id', name: 'Updated Project' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain(errorMessage); expect(mockClient.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: expect.stringContaining('Failed to update project') })); }); it('should validate required fields', async () => { await expect(tool.execute({ name: 'Updated Project' // Missing project_id })).rejects.toThrow(); }); it('should validate field types', async () => { await expect(tool.execute({ project_id: 'test-id', network: 3 // Invalid network value })).rejects.toThrow(); await expect(tool.execute({ project_id: 'test-id', archive_in: 13 // Invalid archive_in value })).rejects.toThrow(); }); }); ``` -------------------------------------------------------------------------------- /src/tools/issues/update.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, ToolResponse } from '../../types/mcp.js'; import { IssuesClient } from '../../api/issues/client.js'; import { UpdateIssueData, IssuePriority } from '../../api/issues/types.js'; import { PlaneInstanceConfig } from '../../api/types/config.js'; interface UpdateIssueInput extends UpdateIssueData { workspace_slug?: string; project_id: string; issue_id: string; } export class UpdateIssueTool implements Tool { private issuesClient: IssuesClient; private instance: PlaneInstanceConfig; name = 'claudeus_plane_issues__update'; description = 'Updates an existing issue in a Plane project'; status = 'enabled' as const; inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project containing the issue' }, issue_id: { type: 'string', description: 'The ID of the issue to update' }, name: { type: 'string', description: 'The new name/title of the issue' }, description_html: { type: 'string', description: 'The new HTML description of the issue' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'The new priority of the issue' }, start_date: { type: 'string', format: 'date', description: 'The new start date of the issue (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'The new target date of the issue (YYYY-MM-DD)' }, estimate_point: { type: 'number', description: 'The new story points or time estimate for the issue' }, state: { type: 'string', description: 'The new state ID for the issue' }, assignees: { type: 'array', items: { type: 'string' }, description: 'New array of user IDs to assign to the issue' }, labels: { type: 'array', items: { type: 'string' }, description: 'New array of label IDs to apply to the issue' }, parent: { type: 'string', description: 'New parent issue ID (for sub-issues)' }, is_draft: { type: 'boolean', description: 'Whether this issue should be marked as draft' }, archived_at: { type: 'string', format: 'date-time', description: 'When to archive the issue (ISO 8601 format)' }, completed_at: { type: 'string', format: 'date-time', description: 'When the issue was completed (ISO 8601 format)' } }, required: ['project_id', 'issue_id'] }; constructor(instance: PlaneInstanceConfig) { this.instance = instance; this.issuesClient = new IssuesClient(this.instance); } async execute(args: Record<string, unknown>): Promise<ToolResponse> { // Type cast with validation const input = args as unknown as UpdateIssueInput; if (!this.validateInput(input)) { return { isError: true, content: [{ type: 'text', text: 'Invalid input: missing required fields' }] }; } const { workspace_slug = this.instance.defaultWorkspace, project_id, issue_id, ...updateData } = input; // Validate workspace if (!workspace_slug) { return { isError: true, content: [{ type: 'text', text: 'Workspace slug is required' }] }; } // Validate project ID if (!project_id) { return { isError: true, content: [{ type: 'text', text: 'Project ID is required' }] }; } // Validate issue ID if (!issue_id) { return { isError: true, content: [{ type: 'text', text: 'Issue ID is required' }] }; } // Validate that at least one field is being updated if (Object.keys(updateData).length === 0) { return { isError: true, content: [{ type: 'text', text: 'At least one field must be provided for update' }] }; } try { const response = await this.issuesClient.update( workspace_slug, project_id, issue_id, updateData ); return { content: [{ type: 'text', text: JSON.stringify(response) }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { isError: true, content: [{ type: 'text', text: `Failed to update issue: ${errorMessage}` }] }; } } private validateInput(input: unknown): input is UpdateIssueInput { if (typeof input !== 'object' || input === null) return false; const data = input as Record<string, unknown>; return typeof data.project_id === 'string' && typeof data.issue_id === 'string'; } } ``` -------------------------------------------------------------------------------- /src/tools/projects/__tests__/create.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CreateProjectTool } from '../create.js'; import { PlaneApiClient } from '../../../api/client.js'; import { PlaneInstance } from '../../../config/plane-config.js'; // Mock the PlaneApiClient vi.mock('../../../api/client.js', () => { return { PlaneApiClient: vi.fn().mockImplementation((instance, context) => ({ instance, createProject: vi.fn(), notify: vi.fn() })) }; }); describe('CreateProjectTool', () => { let tool: CreateProjectTool; let mockClient: PlaneApiClient; beforeEach(() => { // Create a mock instance const mockInstance: PlaneInstance = { name: 'test', baseUrl: 'https://test.plane.so', apiKey: 'test-key', defaultWorkspace: 'test-workspace' }; // Create a mock context const mockContext = { progressToken: '123', workspace: 'test-workspace' }; // Create a mock client mockClient = new PlaneApiClient(mockInstance, mockContext); // Create the tool instance tool = new CreateProjectTool(mockClient); }); it('should create a project with minimal required fields', async () => { const mockProject = { id: 'test-id', name: 'Test Project', identifier: 'TEST' }; (mockClient.createProject as any).mockResolvedValue(mockProject); const result = await tool.execute({ name: 'Test Project', identifier: 'TEST' }); expect(mockClient.createProject).toHaveBeenCalledWith('test-workspace', { name: 'Test Project', identifier: 'TEST' }); expect(result.content[0].text).toContain('Successfully created project'); expect(result.content[0].text).toContain(mockProject.id); }); it('should create a project with all optional fields', async () => { const mockProject = { id: 'test-id', name: 'Test Project', identifier: 'TEST', description: 'Test Description', network: 2, emoji: '1f680', module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: false }; (mockClient.createProject as any).mockResolvedValue(mockProject); const result = await tool.execute({ name: 'Test Project', identifier: 'TEST', description: 'Test Description', network: 2, emoji: '1f680', module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: false }); expect(mockClient.createProject).toHaveBeenCalledWith('test-workspace', { name: 'Test Project', identifier: 'TEST', description: 'Test Description', network: 2, emoji: '1f680', module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: false }); expect(result.content[0].text).toContain('Successfully created project'); expect(result.content[0].text).toContain(mockProject.id); }); it('should use provided workspace instead of default', async () => { const mockProject = { id: 'test-id', name: 'Test Project', identifier: 'TEST' }; (mockClient.createProject as any).mockResolvedValue(mockProject); await tool.execute({ workspace_slug: 'custom-workspace', name: 'Test Project', identifier: 'TEST' }); expect(mockClient.createProject).toHaveBeenCalledWith('custom-workspace', { name: 'Test Project', identifier: 'TEST' }); }); it('should handle API errors', async () => { const errorMessage = 'API Error: Project creation failed'; (mockClient.createProject as any).mockRejectedValue(new Error(errorMessage)); const result = await tool.execute({ name: 'Test Project', identifier: 'TEST' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain(errorMessage); expect(mockClient.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: expect.stringContaining('Failed to create project') })); }); it('should validate required fields', async () => { await expect(tool.execute({ name: 'Test Project' // Missing identifier })).rejects.toThrow(); await expect(tool.execute({ identifier: 'TEST' // Missing name })).rejects.toThrow(); }); it('should validate field types', async () => { await expect(tool.execute({ name: 'Test Project', identifier: 'TEST', network: 3 // Invalid network value })).rejects.toThrow(); await expect(tool.execute({ name: 'Test Project', identifier: 'TEST', archive_in: 13 // Invalid archive_in value })).rejects.toThrow(); }); }); ``` -------------------------------------------------------------------------------- /src/test/mcp-test-harness.ts: -------------------------------------------------------------------------------- ```typescript import { EventEmitter } from 'events'; import dummyProjects from '@/dummy-data/projects.json' assert { type: 'json' }; export interface MCPContentItem { type: string; text: string; } export interface MCPMessage { jsonrpc: '2.0'; id: number; method?: string; params?: Record<string, unknown>; result?: { content?: MCPContentItem[]; capabilities?: Record<string, unknown>; [key: string]: unknown; }; error?: { code: number; message: string; data?: unknown; }; } interface Transport { onMessage: (handler: (message: MCPMessage) => void) => void; send: (message: MCPMessage) => void; } class InMemoryTransport implements Transport { private messageHandler?: (message: MCPMessage) => void; private otherTransport?: InMemoryTransport; onMessage(handler: (message: MCPMessage) => void): void { this.messageHandler = handler; } send(message: MCPMessage): void { if (this.otherTransport?.messageHandler) { this.otherTransport.messageHandler(message); } } static createLinkedPair(): [InMemoryTransport, InMemoryTransport] { const client = new InMemoryTransport(); const server = new InMemoryTransport(); client.otherTransport = server; server.otherTransport = client; return [client, server]; } } export class MCPTestHarness { private clientTransport: InMemoryTransport; private serverTransport: InMemoryTransport; private responseHandlers = new Map<number, (response: MCPMessage) => void>(); private isConnected = false; private nextMessageId = 1; constructor() { [this.clientTransport, this.serverTransport] = InMemoryTransport.createLinkedPair(); // Set up server-side message handling this.serverTransport.onMessage((message: MCPMessage) => { if (message.method === 'initialize') { this.handleInitialize(message); } else if (message.method === 'tool/call') { this.handleToolCall(message); } }); // Set up client-side message handling this.clientTransport.onMessage((message: MCPMessage) => { if (message.id && this.responseHandlers.has(message.id)) { const handler = this.responseHandlers.get(message.id)!; handler(message); } }); } private handleInitialize(message: MCPMessage): void { if (!message.id) { return; } this.serverTransport.send({ jsonrpc: '2.0', id: message.id, result: { capabilities: { sampling: {}, roots: { listChanged: true } } } }); } private handleToolCall(message: MCPMessage): void { if (!message.id || !message.params) { return; } const { name, args } = message.params as { name: string; args: Record<string, unknown> }; if (name === 'claudeus_plane_projects__list') { if (!args.workspace) { this.serverTransport.send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'Error: Missing required workspace parameter' }] } }); return; } if (args.workspace === 'invalid-workspace-id') { this.serverTransport.send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'Error: Invalid workspace ID' }] } }); return; } // Return actual dummy projects for valid workspace this.serverTransport.send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: JSON.stringify(dummyProjects) }] } }); } } async connect(): Promise<MCPMessage> { if (this.isConnected) { throw new Error('Already connected'); } this.isConnected = true; return this.sendInitialize(); } async sendInitialize(): Promise<MCPMessage> { const initMessage: MCPMessage = { jsonrpc: '2.0', id: this.nextMessageId++, method: 'initialize', params: { capabilities: { sampling: {}, roots: { listChanged: true } } } }; return this.sendMessage(initMessage); } async sendMessage(message: MCPMessage): Promise<MCPMessage> { if (!this.isConnected) { throw new Error('Not connected'); } if (!message.id) { throw new Error('Message must have an ID'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.responseHandlers.delete(message.id!); reject(new Error('Message timeout')); }, 5000); this.responseHandlers.set(message.id, (response: MCPMessage) => { clearTimeout(timeout); this.responseHandlers.delete(message.id!); resolve(response); }); this.clientTransport.send(message); }); } async callTool(name: string, args: Record<string, unknown>): Promise<MCPMessage> { const message: MCPMessage = { jsonrpc: '2.0', id: this.nextMessageId++, method: 'tool/call', params: { name, args } }; return this.sendMessage(message); } onServerMessage(message: MCPMessage): void { this.serverTransport.send(message); } clearHandlers(): void { this.responseHandlers.clear(); this.isConnected = false; } } ``` -------------------------------------------------------------------------------- /src/dummy-data/projects.json: -------------------------------------------------------------------------------- ```json { "grouped_by": null, "sub_grouped_by": null, "total_count": 3, "next_cursor": "1000:1:0", "prev_cursor": "1000:-1:1", "next_page_results": false, "prev_page_results": false, "count": 3, "total_pages": 1, "total_results": 3, "extra_stats": null, "results": [ { "id": "01234567-89ab-cdef-0123-456789abcdef", "total_members": 3, "total_cycles": 2, "total_modules": 1, "is_member": true, "sort_order": 65535, "member_role": 20, "is_deployed": true, "cover_image_url": "https://images.unsplash.com/photo-1234567890", "inbox_view": true, "created_at": "2024-01-01T12:00:00.000000+01:00", "updated_at": "2024-01-02T12:00:00.000000+01:00", "deleted_at": null, "name": "Example Project", "description": "This is an example project description that demonstrates the format of project data in the Plane API. It includes various fields and properties that would typically be associated with a project.", "description_text": null, "description_html": null, "network": 1, "identifier": "EXAMPLE", "emoji": "📝", "icon_prop": null, "module_view": true, "cycle_view": true, "issue_views_view": true, "page_view": true, "intake_view": true, "is_time_tracking_enabled": true, "is_issue_type_enabled": true, "guest_view_all_features": false, "cover_image": "https://images.unsplash.com/photo-1234567890", "archive_in": 0, "close_in": 0, "logo_props": { "icon": { "name": "document", "color": "#4a90e2" }, "in_use": "icon" }, "archived_at": null, "timezone": "UTC", "created_by": "00000000-0000-0000-0000-000000000001", "updated_by": "00000000-0000-0000-0000-000000000001", "workspace": "00000000-0000-0000-0000-000000000002", "default_assignee": "00000000-0000-0000-0000-000000000001", "project_lead": "00000000-0000-0000-0000-000000000001", "cover_image_asset": null, "estimate": "00000000-0000-0000-0000-000000000003", "default_state": null }, { "id": "1cd54b31-bc91-5747-b2b6-c7588ddc76c5", "total_members": 3, "total_cycles": 2, "total_modules": 2, "is_member": true, "sort_order": 65534, "member_role": 20, "is_deployed": true, "cover_image_url": "https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&q=80&ixlib=rb-4.0.3", "inbox_view": true, "created_at": "2025-01-15T10:15:33.224935+01:00", "updated_at": "2025-01-24T16:45:12.445123+01:00", "deleted_at": null, "name": "MCP Framework", "description": "Development of the Multi-Client Protocol (MCP) Framework for standardized API integrations across all SimHop services. This framework will serve as the foundation for all our future client integrations and internal tools.", "description_text": null, "description_html": null, "network": 1, "identifier": "MCP", "emoji": "🔌", "icon_prop": null, "module_view": true, "cycle_view": true, "issue_views_view": true, "page_view": true, "intake_view": true, "is_time_tracking_enabled": true, "is_issue_type_enabled": true, "guest_view_all_features": false, "cover_image": "https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&q=80&ixlib=rb-4.0.3", "archive_in": 0, "close_in": 0, "logo_props": { "icon": { "name": "plug", "color": "#3366ff" }, "in_use": "icon" }, "archived_at": null, "timezone": "UTC", "created_by": "52ab338c-2239-48fe-8e18-588bb17a78fc", "updated_by": "52ab338c-2239-48fe-8e18-588bb17a78fc", "workspace": "6bb6e42b-0bb7-43a2-b561-677dc52df44f", "default_assignee": "52ab338c-2239-48fe-8e18-588bb17a78fc", "project_lead": "52ab338c-2239-48fe-8e18-588bb17a78fc", "cover_image_asset": null, "estimate": "146ba6b4-a645-49a5-a57f-59800f5a8cd6", "default_state": null }, { "id": "2ef65c42-cd92-6858-c3c7-d8699eed87d6", "total_members": 2, "total_cycles": 3, "total_modules": 1, "is_member": true, "sort_order": 65533, "member_role": 20, "is_deployed": false, "cover_image_url": "https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&q=80&ixlib=rb-4.0.3", "inbox_view": true, "created_at": "2025-01-20T14:30:45.334123+01:00", "updated_at": "2025-01-25T09:15:22.556789+01:00", "deleted_at": null, "name": "AI Assistant Hub", "description": "Creation of an AI-powered assistant hub that integrates with our existing tools and services. This project focuses on developing intelligent automation and support features to enhance productivity across all SimHop operations.", "description_text": null, "description_html": null, "network": 3, "identifier": "AIH", "emoji": "🤖", "icon_prop": null, "module_view": true, "cycle_view": true, "issue_views_view": true, "page_view": true, "intake_view": true, "is_time_tracking_enabled": true, "is_issue_type_enabled": true, "guest_view_all_features": false, "cover_image": "https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&q=80&ixlib=rb-4.0.3", "archive_in": 0, "close_in": 0, "logo_props": { "icon": { "name": "robot", "color": "#00cc88" }, "in_use": "icon" }, "archived_at": null, "timezone": "UTC", "created_by": "52ab338c-2239-48fe-8e18-588bb17a78fc", "updated_by": "52ab338c-2239-48fe-8e18-588bb17a78fc", "workspace": "6bb6e42b-0bb7-43a2-b561-677dc52df44f", "default_assignee": "52ab338c-2239-48fe-8e18-588bb17a78fc", "project_lead": "52ab338c-2239-48fe-8e18-588bb17a78fc", "cover_image_asset": null, "estimate": "146ba6b4-a645-49a5-a57f-59800f5a8cd6", "default_state": null } ] } ``` -------------------------------------------------------------------------------- /src/tools/projects/update.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Tool, ToolResponse } from '../../types/mcp.js'; import { PlaneApiClient } from '../../api/client.js'; const inputSchema = { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace containing the project. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to update (required)' }, // Optional fields - any field can be updated name: { type: 'string', description: 'New name for the project' }, identifier: { type: 'string', description: 'New unique identifier for the project in the workspace. Example: "PROJ1"' }, description: { type: 'string', description: 'New description for the project' }, network: { type: 'integer', description: 'Project visibility: 0 for Secret (private), 2 for Public', enum: [0, 2] }, emoji: { type: 'string', description: 'HTML emoji DEX code without the "&#". Example: "1f680" for rocket' }, icon_prop: { type: 'object', description: 'Custom icon properties for the project' }, module_view: { type: 'boolean', description: 'Enable/disable module view for the project' }, cycle_view: { type: 'boolean', description: 'Enable/disable cycle view for the project' }, issue_views_view: { type: 'boolean', description: 'Enable/disable project views for the project' }, page_view: { type: 'boolean', description: 'Enable/disable pages for the project' }, inbox_view: { type: 'boolean', description: 'Enable/disable intake for the project' }, cover_image: { type: 'string', description: 'URL for the project cover image' }, archive_in: { type: 'integer', description: 'Months in which issues should be automatically archived (0-12)', minimum: 0, maximum: 12 }, close_in: { type: 'integer', description: 'Months in which issues should be auto-closed (0-12)', minimum: 0, maximum: 12 }, default_assignee: { type: 'string', description: 'UUID of the user who will be the default assignee for issues' }, project_lead: { type: 'string', description: 'UUID of the user who will lead the project' }, estimate: { type: 'string', description: 'UUID of the estimate to use for the project' }, default_state: { type: 'string', description: 'Default state to use when issues are auto-closed' } }, required: ['project_id'] }; const zodInputSchema = z.object({ workspace_slug: z.string().optional(), project_id: z.string(), // All fields are optional for updates name: z.string().optional(), identifier: z.string().optional(), description: z.string().optional(), network: z.number().min(0).max(2).optional(), emoji: z.string().optional(), icon_prop: z.record(z.unknown()).optional(), module_view: z.boolean().optional(), cycle_view: z.boolean().optional(), issue_views_view: z.boolean().optional(), page_view: z.boolean().optional(), inbox_view: z.boolean().optional(), cover_image: z.string().nullable().optional(), archive_in: z.number().min(0).max(12).optional(), close_in: z.number().min(0).max(12).optional(), default_assignee: z.string().nullable().optional(), project_lead: z.string().nullable().optional(), estimate: z.string().nullable().optional(), default_state: z.string().nullable().optional() }); export class UpdateProjectTool implements Tool { name = 'claudeus_plane_projects__update'; description = 'Updates an existing project in a workspace. If no workspace is specified, uses the default workspace. Allows updating any project properties including name, visibility, views, and automation settings.'; status: 'enabled' | 'disabled' = 'enabled'; inputSchema = inputSchema; constructor(private client: PlaneApiClient) {} async execute(args: Record<string, unknown>): Promise<ToolResponse> { const input = zodInputSchema.parse(args); const { workspace_slug, project_id, ...updateData } = input; try { // Use the workspace from config if not provided const workspace = workspace_slug || this.client.instance.defaultWorkspace; if (!workspace) { throw new Error('No workspace provided or configured'); } this.client.notify({ type: 'info', message: `Updating project ${project_id} in workspace: ${workspace}`, source: this.name, data: { workspace, project_id, ...updateData } }); const project = await this.client.updateProject(workspace, project_id, updateData); this.client.notify({ type: 'info', message: `Successfully updated project ${project_id}`, data: { projectId: project.id, workspace }, source: this.name }); return { content: [{ type: 'text', text: `Successfully updated project (ID: ${project.id}) in workspace "${workspace}"\n\nUpdated project details:\n${JSON.stringify(project, null, 2)}` }] }; } catch (error) { if (error instanceof Error) { this.client.notify({ type: 'error', message: `Failed to update project: ${error.message}`, data: { error: error.message, workspace: workspace_slug, project_id }, source: this.name }); return { isError: true, content: [{ type: 'text', text: `Failed to update project: ${error.message}` }] }; } throw error; } } } ``` -------------------------------------------------------------------------------- /src/mcp/tools.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ServerResult } from '@modelcontextprotocol/sdk/types.js'; import { PlaneApiClient } from '../api/client.js'; import { allTools } from '../tools/index.js'; import { DEFAULT_INSTANCE } from '../config/plane-config.js'; import { z } from 'zod'; import { Tool, ToolResponse } from '../types/mcp.js'; import { McpServer } from './server.js'; interface MCPMessage { jsonrpc: '2.0'; id?: number; method?: string; params?: Record<string, unknown>; result?: { content?: Array<{ type: string; text: string }>; _meta?: Record<string, unknown>; }; } function constructResourceUri(name: string, url: string): string { return `plane://${name}@${new URL(url).hostname}`; } export function registerTools(server: Server, clients: Map<string, PlaneApiClient>) { // Register resource handlers server.setRequestHandler(ListResourcesRequestSchema, async () => { const resources = Array.from(clients.entries()).map(([name, client]) => ({ id: name, name: `Instance: ${name}`, type: "plane_instance", uri: constructResourceUri(name, client.baseUrl), metadata: { url: client.baseUrl, authType: "api_key" } })); return { resources }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (!request.params?.uri || typeof request.params.uri !== 'string') { throw { code: -32602, message: 'Resource URI must be a non-empty string' }; } const match = request.params.uri.match(/^plane:\/\/([^@]+)@/); if (!match) { throw { code: -32602, message: `Invalid Plane resource URI format: ${request.params.uri}` }; } const name = match[1]; const client = clients.get(name); if (!client) { throw { code: -32602, message: `Unknown instance: ${name}` }; } return { resource: { id: name, name: `Instance: ${name}`, type: "plane_instance", uri: constructResourceUri(name, client.baseUrl), metadata: { url: client.baseUrl, authType: "api_key", capabilities: { projects: true, issues: true, cycles: true, modules: true } } }, contents: [{ type: 'text', uri: constructResourceUri(name, client.baseUrl), text: JSON.stringify({ url: client.baseUrl, authType: "api_key", capabilities: { projects: true, issues: true, cycles: true, modules: true } }, null, 2) }] }; }); server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => { const resourceId = request.params?.id; if (!resourceId || typeof resourceId !== 'string') { return { resourceTemplates: [] }; } const client = clients.get(resourceId); if (!client) { return { resourceTemplates: [] }; } return { resourceTemplates: [{ id: "claudeus_plane_discover_endpoints_template", name: "Discover Endpoints", description: "Discover available REST API endpoints on this Plane instance", tool: "claudeus_plane_discover_endpoints", arguments: { instance: resourceId } }] }; }); // Register tool handlers server.setRequestHandler(ListToolsRequestSchema, async (request) => { const instance = (request.params?.instance as string) || DEFAULT_INSTANCE; const client = clients.get(instance); if (!client) { throw new Error(`Unknown instance: ${instance}`); } return { tools: allTools.map(tool => ({ name: tool.name, description: tool.description, status: tool.status || 'enabled', inputSchema: tool.inputSchema || { type: 'object', properties: {} } })) }; }); server.setRequestHandler(CallToolRequestSchema, async (request): Promise<ServerResult> => { const { name, arguments: args } = request.params; const toolDef = allTools.find(t => t.name === name); if (!toolDef) { throw new Error(`Tool not found: ${name}`); } const instance = (args?.instance as string) || DEFAULT_INSTANCE; const client = clients.get(instance); if (!client) { throw new Error(`Unknown instance: ${instance}`); } const toolInstance = new toolDef.class(client); const result = await toolInstance.execute(args || {}); return { content: result.content, _meta: request.params._meta }; }); } interface ExecutableTool extends Tool { execute(args: Record<string, unknown>): Promise<ToolResponse>; } type ToolClass = new (client: PlaneApiClient) => ExecutableTool; export function setupToolHandlers(server: Server, client: PlaneApiClient): void { // Register tool list handler server.setRequestHandler(z.object({ method: z.literal('tools/list') }), async () => { return { tools: allTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })) }; }); // Register tool call handler server.setRequestHandler(z.object({ method: z.literal('tools/call'), params: z.object({ name: z.string(), arguments: z.record(z.unknown()).optional(), _meta: z.object({ progressToken: z.union([z.string(), z.number()]).optional() }).optional() }) }), async (request) => { const { name, arguments: args } = request.params; const toolDef = allTools.find(t => t.name === name); if (!toolDef) { throw new Error(`Tool not found: ${name}`); } const ToolClass = toolDef.class as ToolClass; const toolInstance = new ToolClass(client); const result = await toolInstance.execute(args || {}); return { content: result.content, _meta: request.params._meta }; }); } ``` -------------------------------------------------------------------------------- /src/mcp/server.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express, { Express, Response } from 'express'; import cors from 'cors'; import { z } from 'zod'; import { PromptDefinition, PromptContext } from '../types/prompt.js'; import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; type ServerCapabilities = { [key: string]: unknown; prompts?: { list?: boolean, execute?: boolean }; tools?: { list?: boolean, call?: boolean }; resources?: { list?: boolean, read?: boolean }; }; interface Connection { id: string; transport: any; initialized: boolean; } interface ServerRequest<T = unknown> { method: string; params: T; } export class McpServer { private server: Server; private app: Express; private connections: Map<string, Connection> = new Map(); private nextConnectionId = 1; private capabilities = { prompts: { listChanged: true }, tools: { listChanged: true }, resources: { listChanged: true } }; private registeredPrompts: PromptDefinition[] = []; constructor(name: string = 'claudeus-plane-mcp', version: string = '1.0.0') { // Create server with proper initialization this.server = new Server( { name, version }, { capabilities: this.capabilities } ); this.app = express(); this.app.use(cors()); this.app.use(express.json()); // Register resource handlers first this.server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [] }; // Placeholder - will be overridden by tools.ts }); this.server.setRequestHandler(ReadResourceRequestSchema, async () => { return { resource: null, contents: [] }; // Placeholder - will be overridden by tools.ts }); this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { return { resourceTemplates: [] }; // Placeholder - will be overridden by tools.ts }); // Register prompt handlers using SDK schemas this.server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: this.registeredPrompts.map(p => ({ name: p.name, description: p.description, schema: p.schema })) }; }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { const promptName = request.params?.name; if (!promptName || typeof promptName !== 'string') { throw new Error('Prompt name is required'); } const prompt = this.registeredPrompts.find(p => p.name === promptName); if (!prompt) { throw new Error(`Unknown prompt: ${promptName}`); } return { name: prompt.name, description: prompt.description, schema: prompt.schema }; }); // Then register initialization and shutdown handlers const initializeSchema = z.object({ method: z.literal('initialize'), params: z.object({ capabilities: z.record(z.unknown()) }) }); const shutdownSchema = z.object({ method: z.literal('shutdown') }); this.server.setRequestHandler(initializeSchema, async (request) => { if (!this.isValidCapabilities(request.params.capabilities)) { throw { code: -32602, message: 'Invalid params: capabilities must be an object' }; } return { protocolVersion: '2024-11-05', serverInfo: { name, version }, capabilities: this.capabilities }; }); this.server.setRequestHandler(shutdownSchema, async () => { return { success: true }; }); } private isValidCapabilities(capabilities: unknown): boolean { return typeof capabilities === 'object' && capabilities !== null && !Array.isArray(capabilities); } private trackConnection(transport: any): void { const id = `conn_${this.nextConnectionId++}`; this.connections.set(id, { id, transport, initialized: true }); console.error(`🔌 New connection established: ${id}`); } private untrackConnection(transport: any): void { for (const [id, conn] of this.connections.entries()) { if (conn.transport === transport) { this.connections.delete(id); console.error(`🔌 Connection closed: ${id}`); break; } } } getServer(): Server { return this.server; } getApp(): Express { return this.app; } getActiveConnections(): number { return this.connections.size; } async connectStdio(): Promise<void> { const transport = new StdioServerTransport(); this.trackConnection(transport); try { await this.server.connect(transport); } catch (error) { this.untrackConnection(transport); throw error; } } async connectSSE(port = 3000, path = '/sse'): Promise<void> { this.app.get(path, (req, res: Response) => { const transport = new SSEServerTransport(path, res); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); this.trackConnection(transport); this.server.connect(transport).catch(error => { console.error('Failed to connect transport:', error); this.untrackConnection(transport); res.end(); }); res.on('close', () => { this.untrackConnection(transport); }); }); await new Promise<void>((resolve) => { this.app.listen(port, () => { console.error(`Server listening on port ${port}`); resolve(); }); }); } registerPrompt(prompt: PromptDefinition): void { // Register the execute handler for this specific prompt const executeSchema = z.object({ method: z.literal(`prompts/${prompt.name}/execute`), params: z.object({ arguments: z.record(z.unknown()) }) }); this.server.setRequestHandler(executeSchema, async (request, extra) => { try { const context: PromptContext = { workspace: process.env.WORKSPACE_PATH || '', connectionId: 'default' }; // Execute the prompt handler with the arguments const result = await prompt.handler(request.params.arguments, context); console.error(`Executed prompt: ${prompt.name}`); // Ensure we have a properly structured response if (!result?.messages || !Array.isArray(result.messages)) { throw new Error('Prompt handler must return a messages array'); } return { messages: result.messages, metadata: result.metadata || {}, tools: [] }; } catch (error) { console.error(`Failed to execute prompt ${prompt.name}:`, error); return { messages: [{ role: 'assistant', content: { type: 'text', text: `Error executing prompt ${prompt.name}: ${error instanceof Error ? error.message : String(error)}` } }], metadata: { error: error instanceof Error ? error.message : String(error) }, tools: [] }; } }); // Track the registered prompt this.registeredPrompts.push(prompt); console.error(`Registered prompt: ${prompt.name}`); } async initialize(): Promise<void> { try { await this.connectStdio(); console.error('Server initialized successfully'); } catch (error) { console.error('Failed to initialize server:', error); throw error; } } async start(): Promise<void> { try { console.error('Server started successfully'); } catch (error) { console.error('Failed to start server:', error); throw error; } } } ``` -------------------------------------------------------------------------------- /src/tools/projects/__tests__/handlers.test.ts: -------------------------------------------------------------------------------- ```typescript import { ProjectsAPI } from '@/api/projects.js'; import { listProjects, createProject, updateProject, deleteProject } from '@/tools/projects/handlers.js'; import { PlaneInstance } from '@/config/plane-config.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock ProjectsAPI vi.mock('@/api/projects.js', () => ({ ProjectsAPI: vi.fn().mockImplementation((instance) => ({ instance, listProjects: vi.fn(), createProject: vi.fn(), updateProject: vi.fn(), deleteProject: vi.fn() })) })); describe('Project Tool Handlers', () => { let api: ReturnType<typeof vi.mocked<ProjectsAPI>>; const mockInstance: PlaneInstance = { name: 'test', baseUrl: 'https://test.plane.so', defaultWorkspace: 'default-workspace', apiKey: 'test-key' }; beforeEach(() => { api = new ProjectsAPI(mockInstance) as ReturnType<typeof vi.mocked<ProjectsAPI>>; }); describe('listProjects', () => { it('should list projects from default workspace', async () => { const mockProjects = [{ id: '123e4567-e89b-12d3-a456-426614174000', name: 'Test Project', identifier: 'TEST', description: null, network: 1, workspace: '123e4567-e89b-12d3-a456-426614174001', project_lead: null, default_assignee: null, is_member: true, member_role: 1, total_members: 1, total_cycles: 0, total_modules: 0, module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: true, created_at: '2024-01-25T00:00:00Z', updated_at: '2024-01-25T00:00:00Z', created_by: '123e4567-e89b-12d3-a456-426614174002', updated_by: '123e4567-e89b-12d3-a456-426614174002' }]; vi.mocked(api.listProjects).mockResolvedValue(mockProjects); const result = await listProjects(api, {}); expect(api.listProjects).toHaveBeenCalledWith('default-workspace', { include_archived: undefined }); expect(result.content[0].text).toBe(JSON.stringify(mockProjects, null, 2)); }); it('should list projects from specified workspace', async () => { const mockProjects = [{ id: '123e4567-e89b-12d3-a456-426614174000', name: 'Test Project', identifier: 'TEST', description: null, network: 1, workspace: '123e4567-e89b-12d3-a456-426614174001', project_lead: null, default_assignee: null, is_member: true, member_role: 1, total_members: 1, total_cycles: 0, total_modules: 0, module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: true, created_at: '2024-01-25T00:00:00Z', updated_at: '2024-01-25T00:00:00Z', created_by: '123e4567-e89b-12d3-a456-426614174002', updated_by: '123e4567-e89b-12d3-a456-426614174002' }]; vi.mocked(api.listProjects).mockResolvedValue(mockProjects); const result = await listProjects(api, { workspace_slug: 'custom-workspace' }); expect(api.listProjects).toHaveBeenCalledWith('custom-workspace', { include_archived: undefined }); expect(result.content[0].text).toBe(JSON.stringify(mockProjects, null, 2)); }); it('should handle errors gracefully', async () => { vi.mocked(api.listProjects).mockRejectedValue(new Error('API Error')); await expect(listProjects(api, {})) .rejects .toThrow('Failed to list projects: API Error'); }); }); describe('createProject', () => { it('should create a project in default workspace', async () => { const mockProject = { id: '123e4567-e89b-12d3-a456-426614174000', name: 'New Project', identifier: 'NEW', description: null, network: 1, workspace: '123e4567-e89b-12d3-a456-426614174001', project_lead: null, default_assignee: null, is_member: true, member_role: 1, total_members: 1, total_cycles: 0, total_modules: 0, module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: true, created_at: '2024-01-25T00:00:00Z', updated_at: '2024-01-25T00:00:00Z', created_by: '123e4567-e89b-12d3-a456-426614174002', updated_by: '123e4567-e89b-12d3-a456-426614174002' }; vi.mocked(api.createProject).mockResolvedValue(mockProject); const result = await createProject(api, { name: 'New Project', identifier: 'NEW' }); expect(api.createProject).toHaveBeenCalledWith('default-workspace', { name: 'New Project', identifier: 'NEW' }); expect(result.content[0].text).toBe(JSON.stringify(mockProject, null, 2)); }); it('should handle validation errors', async () => { await expect(createProject(api, {})) .rejects .toThrow(); }); }); describe('updateProject', () => { it('should update a project', async () => { const mockProject = { id: '123e4567-e89b-12d3-a456-426614174000', name: 'Updated Project', identifier: 'UPD', description: null, network: 1, workspace: '123e4567-e89b-12d3-a456-426614174001', project_lead: null, default_assignee: null, is_member: true, member_role: 1, total_members: 1, total_cycles: 0, total_modules: 0, module_view: true, cycle_view: true, issue_views_view: true, page_view: true, inbox_view: true, created_at: '2024-01-25T00:00:00Z', updated_at: '2024-01-25T00:00:00Z', created_by: '123e4567-e89b-12d3-a456-426614174002', updated_by: '123e4567-e89b-12d3-a456-426614174002' }; vi.mocked(api.updateProject).mockResolvedValue(mockProject); const result = await updateProject(api, { project_id: '1', name: 'Updated Project' }); expect(api.updateProject).toHaveBeenCalledWith('default-workspace', '1', { name: 'Updated Project' }); expect(result.content[0].text).toBe(JSON.stringify(mockProject, null, 2)); }); it('should handle missing project_id', async () => { await expect(updateProject(api, { name: 'Test' })) .rejects .toThrow(); }); }); describe('deleteProject', () => { it('should delete a project', async () => { vi.mocked(api.deleteProject).mockResolvedValue(undefined); const result = await deleteProject(api, { project_id: '1' }); expect(api.deleteProject).toHaveBeenCalledWith('default-workspace', '1'); expect(JSON.parse(result.content[0].text)).toEqual({ success: true, message: 'Project deleted successfully' }); }); it('should handle deletion errors', async () => { vi.mocked(api.deleteProject).mockRejectedValue(new Error('Not found')); await expect(deleteProject(api, { project_id: '1' })) .rejects .toThrow('Failed to delete project: Not found'); }); }); }); ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "ES2022", "lib": ["ES2022"], // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "NodeNext", "rootDir": "./src", "moduleResolution": "NodeNext", "baseUrl": ".", "paths": { "@/*": ["src/*"], "*": ["src/types/*"] }, "typeRoots": [ "./node_modules/@types", "./src/types" ], // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ "resolveJsonModule": true, // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ "declaration": true, // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ "sourceMap": true, // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ "outDir": "./dist", // "removeComments": true, /* Disable emitting comments. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "newLine": "crlf", /* Set the newline character for emitting files. */ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Type Checking */ "strict": true, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- ```typescript import { ToolWithClass } from '../types/mcp.js'; import { ListProjectsTool } from './projects/list.js'; import { CreateProjectTool } from './projects/create.js'; import { UpdateProjectTool } from './projects/update.js'; import { DeleteProjectTool } from './projects/delete.js'; import { ListIssuesTools } from './issues/list.js'; import { CreateIssueTool } from './issues/create.js'; import { GetIssueTool } from './issues/get.js'; import { UpdateIssueTool } from './issues/update.js'; // Export all tools with their classes export const allTools: ToolWithClass[] = [ { name: 'claudeus_plane_projects__list', description: 'Lists all projects in a Plane workspace. If no workspace is specified, lists projects from the default workspace.', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to list projects from. If not provided, uses the default workspace.' }, include_archived: { type: 'boolean', description: 'Whether to include archived projects', default: false } } }, class: ListProjectsTool }, { name: 'claudeus_plane_projects__create', description: 'Creates a new project in a workspace. If no workspace is specified, uses the default workspace.', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to create the project in. If not provided, uses the default workspace.' }, name: { type: 'string', description: 'The name of the project' }, identifier: { type: 'string', description: 'The unique identifier for the project' }, description: { type: 'string', description: 'A description of the project' } }, required: ['name', 'identifier'] }, class: CreateProjectTool }, { name: 'claudeus_plane_projects__update', description: 'Updates an existing project in a workspace. If no workspace is specified, uses the default workspace.', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to update the project in. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to update.' }, name: { type: 'string', description: 'The new name of the project.' }, description: { type: 'string', description: 'The new description of the project.' }, start_date: { type: 'string', format: 'date', description: 'The new start date of the project.' }, end_date: { type: 'string', format: 'date', description: 'The new end date of the project.' }, status: { type: 'string', description: 'The new status of the project.' } }, required: ['project_id'] }, class: UpdateProjectTool }, { name: 'claudeus_plane_projects__delete', description: 'Deletes an existing project in a workspace. If no workspace is specified, uses the default workspace.', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to delete the project from. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to delete.' } }, required: ['project_id'] }, class: DeleteProjectTool }, { name: 'claudeus_plane_issues__list', description: 'Lists issues in a Plane project', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to list issues from. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to list issues from' }, state: { type: 'string', description: 'Filter issues by state ID' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'Filter issues by priority' }, assignee: { type: 'string', description: 'Filter issues by assignee ID' }, label: { type: 'string', description: 'Filter issues by label ID' }, created_by: { type: 'string', description: 'Filter issues by creator ID' }, start_date: { type: 'string', format: 'date', description: 'Filter issues by start date (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'Filter issues by target date (YYYY-MM-DD)' }, subscriber: { type: 'string', description: 'Filter issues by subscriber ID' }, is_draft: { type: 'boolean', description: 'Filter draft issues', default: false }, archived: { type: 'boolean', description: 'Filter archived issues', default: false }, page: { type: 'number', description: 'Page number (1-based)', default: 1 }, page_size: { type: 'number', description: 'Number of items per page', default: 100 } }, required: ['project_id'] }, class: ListIssuesTools }, { name: 'claudeus_plane_issues__create', description: 'Creates a new issue in a Plane project', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace to create the issue in. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project to create the issue in' }, name: { type: 'string', description: 'The name/title of the issue' }, description_html: { type: 'string', description: 'The HTML description of the issue' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'The priority of the issue', default: 'none' }, start_date: { type: 'string', format: 'date', description: 'The start date of the issue (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'The target date of the issue (YYYY-MM-DD)' }, estimate_point: { type: 'number', description: 'Story points or time estimate for the issue' }, state: { type: 'string', description: 'The state ID for the issue' }, assignees: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs to assign to the issue' }, labels: { type: 'array', items: { type: 'string' }, description: 'Array of label IDs to apply to the issue' }, parent: { type: 'string', description: 'ID of the parent issue (for sub-issues)' }, is_draft: { type: 'boolean', description: 'Whether this is a draft issue', default: false } }, required: ['project_id', 'name'] }, class: CreateIssueTool }, { name: 'claudeus_plane_issues__get', description: 'Gets a single issue by ID from a Plane project', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project containing the issue' }, issue_id: { type: 'string', description: 'The ID of the issue to retrieve' } }, required: ['project_id', 'issue_id'] }, class: GetIssueTool }, { name: 'claudeus_plane_issues__update', description: 'Updates an existing issue in a Plane project', status: 'enabled', inputSchema: { type: 'object', properties: { workspace_slug: { type: 'string', description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' }, project_id: { type: 'string', description: 'The ID of the project containing the issue' }, issue_id: { type: 'string', description: 'The ID of the issue to update' }, name: { type: 'string', description: 'The new name/title of the issue' }, description_html: { type: 'string', description: 'The new HTML description of the issue' }, priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'], description: 'The new priority of the issue' }, start_date: { type: 'string', format: 'date', description: 'The new start date of the issue (YYYY-MM-DD)' }, target_date: { type: 'string', format: 'date', description: 'The new target date of the issue (YYYY-MM-DD)' }, estimate_point: { type: 'number', description: 'The new story points or time estimate for the issue' }, state: { type: 'string', description: 'The new state ID for the issue' }, assignees: { type: 'array', items: { type: 'string' }, description: 'New array of user IDs to assign to the issue' }, labels: { type: 'array', items: { type: 'string' }, description: 'New array of label IDs to apply to the issue' }, parent: { type: 'string', description: 'New parent issue ID (for sub-issues)' }, is_draft: { type: 'boolean', description: 'Whether this issue should be marked as draft' }, archived_at: { type: 'string', format: 'date-time', description: 'When to archive the issue (ISO 8601 format)' }, completed_at: { type: 'string', format: 'date-time', description: 'When the issue was completed (ISO 8601 format)' } }, required: ['project_id', 'issue_id'] }, class: UpdateIssueTool } ]; // Define tool capabilities export const toolCapabilities = { // Projects claudeus_plane_projects__list: true, claudeus_plane_projects__get: false, // Coming soon claudeus_plane_projects__create: true, claudeus_plane_projects__update: true, claudeus_plane_projects__delete: true, // Issues claudeus_plane_issues__list: true, claudeus_plane_issues__get: true, claudeus_plane_issues__create: true, claudeus_plane_issues__update: true, claudeus_plane_issues__delete: false, // Coming soon // Cycles (Coming soon) claudeus_plane_cycles__list: false, claudeus_plane_cycles__get: false, claudeus_plane_cycles__create: false, claudeus_plane_cycles__update: false, claudeus_plane_cycles__delete: false, // Modules (Coming soon) claudeus_plane_modules__list: false, claudeus_plane_modules__get: false, claudeus_plane_modules__create: false, claudeus_plane_modules__update: false, claudeus_plane_modules__delete: false }; ```