This is page 2 of 2. Use http://codebase.md/deus-h/claudeus-plane-mcp?lines=true&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 -------------------------------------------------------------------------------- /src/tools/projects/update.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Tool, ToolResponse } from '../../types/mcp.js'; 3 | import { PlaneApiClient } from '../../api/client.js'; 4 | 5 | const inputSchema = { 6 | type: 'object', 7 | properties: { 8 | workspace_slug: { 9 | type: 'string', 10 | description: 'The slug of the workspace containing the project. If not provided, uses the default workspace.' 11 | }, 12 | project_id: { 13 | type: 'string', 14 | description: 'The ID of the project to update (required)' 15 | }, 16 | // Optional fields - any field can be updated 17 | name: { 18 | type: 'string', 19 | description: 'New name for the project' 20 | }, 21 | identifier: { 22 | type: 'string', 23 | description: 'New unique identifier for the project in the workspace. Example: "PROJ1"' 24 | }, 25 | description: { 26 | type: 'string', 27 | description: 'New description for the project' 28 | }, 29 | network: { 30 | type: 'integer', 31 | description: 'Project visibility: 0 for Secret (private), 2 for Public', 32 | enum: [0, 2] 33 | }, 34 | emoji: { 35 | type: 'string', 36 | description: 'HTML emoji DEX code without the "&#". Example: "1f680" for rocket' 37 | }, 38 | icon_prop: { 39 | type: 'object', 40 | description: 'Custom icon properties for the project' 41 | }, 42 | module_view: { 43 | type: 'boolean', 44 | description: 'Enable/disable module view for the project' 45 | }, 46 | cycle_view: { 47 | type: 'boolean', 48 | description: 'Enable/disable cycle view for the project' 49 | }, 50 | issue_views_view: { 51 | type: 'boolean', 52 | description: 'Enable/disable project views for the project' 53 | }, 54 | page_view: { 55 | type: 'boolean', 56 | description: 'Enable/disable pages for the project' 57 | }, 58 | inbox_view: { 59 | type: 'boolean', 60 | description: 'Enable/disable intake for the project' 61 | }, 62 | cover_image: { 63 | type: 'string', 64 | description: 'URL for the project cover image' 65 | }, 66 | archive_in: { 67 | type: 'integer', 68 | description: 'Months in which issues should be automatically archived (0-12)', 69 | minimum: 0, 70 | maximum: 12 71 | }, 72 | close_in: { 73 | type: 'integer', 74 | description: 'Months in which issues should be auto-closed (0-12)', 75 | minimum: 0, 76 | maximum: 12 77 | }, 78 | default_assignee: { 79 | type: 'string', 80 | description: 'UUID of the user who will be the default assignee for issues' 81 | }, 82 | project_lead: { 83 | type: 'string', 84 | description: 'UUID of the user who will lead the project' 85 | }, 86 | estimate: { 87 | type: 'string', 88 | description: 'UUID of the estimate to use for the project' 89 | }, 90 | default_state: { 91 | type: 'string', 92 | description: 'Default state to use when issues are auto-closed' 93 | } 94 | }, 95 | required: ['project_id'] 96 | }; 97 | 98 | const zodInputSchema = z.object({ 99 | workspace_slug: z.string().optional(), 100 | project_id: z.string(), 101 | // All fields are optional for updates 102 | name: z.string().optional(), 103 | identifier: z.string().optional(), 104 | description: z.string().optional(), 105 | network: z.number().min(0).max(2).optional(), 106 | emoji: z.string().optional(), 107 | icon_prop: z.record(z.unknown()).optional(), 108 | module_view: z.boolean().optional(), 109 | cycle_view: z.boolean().optional(), 110 | issue_views_view: z.boolean().optional(), 111 | page_view: z.boolean().optional(), 112 | inbox_view: z.boolean().optional(), 113 | cover_image: z.string().nullable().optional(), 114 | archive_in: z.number().min(0).max(12).optional(), 115 | close_in: z.number().min(0).max(12).optional(), 116 | default_assignee: z.string().nullable().optional(), 117 | project_lead: z.string().nullable().optional(), 118 | estimate: z.string().nullable().optional(), 119 | default_state: z.string().nullable().optional() 120 | }); 121 | 122 | export class UpdateProjectTool implements Tool { 123 | name = 'claudeus_plane_projects__update'; 124 | 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.'; 125 | status: 'enabled' | 'disabled' = 'enabled'; 126 | inputSchema = inputSchema; 127 | 128 | constructor(private client: PlaneApiClient) {} 129 | 130 | async execute(args: Record<string, unknown>): Promise<ToolResponse> { 131 | const input = zodInputSchema.parse(args); 132 | const { workspace_slug, project_id, ...updateData } = input; 133 | 134 | try { 135 | // Use the workspace from config if not provided 136 | const workspace = workspace_slug || this.client.instance.defaultWorkspace; 137 | if (!workspace) { 138 | throw new Error('No workspace provided or configured'); 139 | } 140 | 141 | this.client.notify({ 142 | type: 'info', 143 | message: `Updating project ${project_id} in workspace: ${workspace}`, 144 | source: this.name, 145 | data: { workspace, project_id, ...updateData } 146 | }); 147 | 148 | const project = await this.client.updateProject(workspace, project_id, updateData); 149 | 150 | this.client.notify({ 151 | type: 'info', 152 | message: `Successfully updated project ${project_id}`, 153 | data: { 154 | projectId: project.id, 155 | workspace 156 | }, 157 | source: this.name 158 | }); 159 | 160 | return { 161 | content: [{ 162 | type: 'text', 163 | text: `Successfully updated project (ID: ${project.id}) in workspace "${workspace}"\n\nUpdated project details:\n${JSON.stringify(project, null, 2)}` 164 | }] 165 | }; 166 | } catch (error) { 167 | if (error instanceof Error) { 168 | this.client.notify({ 169 | type: 'error', 170 | message: `Failed to update project: ${error.message}`, 171 | data: { 172 | error: error.message, 173 | workspace: workspace_slug, 174 | project_id 175 | }, 176 | source: this.name 177 | }); 178 | 179 | return { 180 | isError: true, 181 | content: [{ 182 | type: 'text', 183 | text: `Failed to update project: ${error.message}` 184 | }] 185 | }; 186 | } 187 | throw error; 188 | } 189 | } 190 | } 191 | ``` -------------------------------------------------------------------------------- /src/mcp/tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { 4 | ListToolsRequestSchema, 5 | CallToolRequestSchema, 6 | ListResourcesRequestSchema, 7 | ListResourceTemplatesRequestSchema, 8 | ReadResourceRequestSchema, 9 | ServerResult 10 | } from '@modelcontextprotocol/sdk/types.js'; 11 | import { PlaneApiClient } from '../api/client.js'; 12 | import { allTools } from '../tools/index.js'; 13 | import { DEFAULT_INSTANCE } from '../config/plane-config.js'; 14 | import { z } from 'zod'; 15 | import { Tool, ToolResponse } from '../types/mcp.js'; 16 | import { McpServer } from './server.js'; 17 | 18 | interface MCPMessage { 19 | jsonrpc: '2.0'; 20 | id?: number; 21 | method?: string; 22 | params?: Record<string, unknown>; 23 | result?: { 24 | content?: Array<{ type: string; text: string }>; 25 | _meta?: Record<string, unknown>; 26 | }; 27 | } 28 | 29 | function constructResourceUri(name: string, url: string): string { 30 | return `plane://${name}@${new URL(url).hostname}`; 31 | } 32 | 33 | export function registerTools(server: Server, clients: Map<string, PlaneApiClient>) { 34 | // Register resource handlers 35 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 36 | const resources = Array.from(clients.entries()).map(([name, client]) => ({ 37 | id: name, 38 | name: `Instance: ${name}`, 39 | type: "plane_instance", 40 | uri: constructResourceUri(name, client.baseUrl), 41 | metadata: { 42 | url: client.baseUrl, 43 | authType: "api_key" 44 | } 45 | })); 46 | return { resources }; 47 | }); 48 | 49 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 50 | if (!request.params?.uri || typeof request.params.uri !== 'string') { 51 | throw { code: -32602, message: 'Resource URI must be a non-empty string' }; 52 | } 53 | 54 | const match = request.params.uri.match(/^plane:\/\/([^@]+)@/); 55 | if (!match) { 56 | throw { code: -32602, message: `Invalid Plane resource URI format: ${request.params.uri}` }; 57 | } 58 | 59 | const name = match[1]; 60 | const client = clients.get(name); 61 | if (!client) { 62 | throw { code: -32602, message: `Unknown instance: ${name}` }; 63 | } 64 | 65 | return { 66 | resource: { 67 | id: name, 68 | name: `Instance: ${name}`, 69 | type: "plane_instance", 70 | uri: constructResourceUri(name, client.baseUrl), 71 | metadata: { 72 | url: client.baseUrl, 73 | authType: "api_key", 74 | capabilities: { 75 | projects: true, 76 | issues: true, 77 | cycles: true, 78 | modules: true 79 | } 80 | } 81 | }, 82 | contents: [{ 83 | type: 'text', 84 | uri: constructResourceUri(name, client.baseUrl), 85 | text: JSON.stringify({ 86 | url: client.baseUrl, 87 | authType: "api_key", 88 | capabilities: { 89 | projects: true, 90 | issues: true, 91 | cycles: true, 92 | modules: true 93 | } 94 | }, null, 2) 95 | }] 96 | }; 97 | }); 98 | 99 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => { 100 | const resourceId = request.params?.id; 101 | if (!resourceId || typeof resourceId !== 'string') { 102 | return { resourceTemplates: [] }; 103 | } 104 | 105 | const client = clients.get(resourceId); 106 | if (!client) { 107 | return { resourceTemplates: [] }; 108 | } 109 | 110 | return { 111 | resourceTemplates: [{ 112 | id: "claudeus_plane_discover_endpoints_template", 113 | name: "Discover Endpoints", 114 | description: "Discover available REST API endpoints on this Plane instance", 115 | tool: "claudeus_plane_discover_endpoints", 116 | arguments: { 117 | instance: resourceId 118 | } 119 | }] 120 | }; 121 | }); 122 | 123 | // Register tool handlers 124 | server.setRequestHandler(ListToolsRequestSchema, async (request) => { 125 | const instance = (request.params?.instance as string) || DEFAULT_INSTANCE; 126 | const client = clients.get(instance); 127 | 128 | if (!client) { 129 | throw new Error(`Unknown instance: ${instance}`); 130 | } 131 | 132 | return { 133 | tools: allTools.map(tool => ({ 134 | name: tool.name, 135 | description: tool.description, 136 | status: tool.status || 'enabled', 137 | inputSchema: tool.inputSchema || { type: 'object', properties: {} } 138 | })) 139 | }; 140 | }); 141 | 142 | server.setRequestHandler(CallToolRequestSchema, async (request): Promise<ServerResult> => { 143 | const { name, arguments: args } = request.params; 144 | const toolDef = allTools.find(t => t.name === name); 145 | 146 | if (!toolDef) { 147 | throw new Error(`Tool not found: ${name}`); 148 | } 149 | 150 | const instance = (args?.instance as string) || DEFAULT_INSTANCE; 151 | const client = clients.get(instance); 152 | if (!client) { 153 | throw new Error(`Unknown instance: ${instance}`); 154 | } 155 | 156 | const toolInstance = new toolDef.class(client); 157 | const result = await toolInstance.execute(args || {}); 158 | 159 | return { 160 | content: result.content, 161 | _meta: request.params._meta 162 | }; 163 | }); 164 | } 165 | 166 | interface ExecutableTool extends Tool { 167 | execute(args: Record<string, unknown>): Promise<ToolResponse>; 168 | } 169 | 170 | type ToolClass = new (client: PlaneApiClient) => ExecutableTool; 171 | 172 | export function setupToolHandlers(server: Server, client: PlaneApiClient): void { 173 | // Register tool list handler 174 | server.setRequestHandler(z.object({ 175 | method: z.literal('tools/list') 176 | }), async () => { 177 | return { 178 | tools: allTools.map(tool => ({ 179 | name: tool.name, 180 | description: tool.description, 181 | inputSchema: tool.inputSchema 182 | })) 183 | }; 184 | }); 185 | 186 | // Register tool call handler 187 | server.setRequestHandler(z.object({ 188 | method: z.literal('tools/call'), 189 | params: z.object({ 190 | name: z.string(), 191 | arguments: z.record(z.unknown()).optional(), 192 | _meta: z.object({ 193 | progressToken: z.union([z.string(), z.number()]).optional() 194 | }).optional() 195 | }) 196 | }), async (request) => { 197 | const { name, arguments: args } = request.params; 198 | const toolDef = allTools.find(t => t.name === name); 199 | 200 | if (!toolDef) { 201 | throw new Error(`Tool not found: ${name}`); 202 | } 203 | 204 | const ToolClass = toolDef.class as ToolClass; 205 | const toolInstance = new ToolClass(client); 206 | const result = await toolInstance.execute(args || {}); 207 | 208 | return { 209 | content: result.content, 210 | _meta: request.params._meta 211 | }; 212 | }); 213 | } ``` -------------------------------------------------------------------------------- /src/mcp/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 4 | import express, { Express, Response } from 'express'; 5 | import cors from 'cors'; 6 | import { z } from 'zod'; 7 | import { PromptDefinition, PromptContext } from '../types/prompt.js'; 8 | import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; 9 | import { 10 | ListResourcesRequestSchema, 11 | ReadResourceRequestSchema, 12 | ListResourceTemplatesRequestSchema, 13 | ListPromptsRequestSchema, 14 | GetPromptRequestSchema 15 | } from '@modelcontextprotocol/sdk/types.js'; 16 | 17 | type ServerCapabilities = { 18 | [key: string]: unknown; 19 | prompts?: { list?: boolean, execute?: boolean }; 20 | tools?: { list?: boolean, call?: boolean }; 21 | resources?: { list?: boolean, read?: boolean }; 22 | }; 23 | 24 | interface Connection { 25 | id: string; 26 | transport: any; 27 | initialized: boolean; 28 | } 29 | 30 | interface ServerRequest<T = unknown> { 31 | method: string; 32 | params: T; 33 | } 34 | 35 | export class McpServer { 36 | private server: Server; 37 | private app: Express; 38 | private connections: Map<string, Connection> = new Map(); 39 | private nextConnectionId = 1; 40 | private capabilities = { 41 | prompts: { listChanged: true }, 42 | tools: { listChanged: true }, 43 | resources: { listChanged: true } 44 | }; 45 | private registeredPrompts: PromptDefinition[] = []; 46 | 47 | constructor(name: string = 'claudeus-plane-mcp', version: string = '1.0.0') { 48 | // Create server with proper initialization 49 | this.server = new Server( 50 | { name, version }, 51 | { capabilities: this.capabilities } 52 | ); 53 | 54 | this.app = express(); 55 | this.app.use(cors()); 56 | this.app.use(express.json()); 57 | 58 | // Register resource handlers first 59 | this.server.setRequestHandler(ListResourcesRequestSchema, async () => { 60 | return { resources: [] }; // Placeholder - will be overridden by tools.ts 61 | }); 62 | 63 | this.server.setRequestHandler(ReadResourceRequestSchema, async () => { 64 | return { resource: null, contents: [] }; // Placeholder - will be overridden by tools.ts 65 | }); 66 | 67 | this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 68 | return { resourceTemplates: [] }; // Placeholder - will be overridden by tools.ts 69 | }); 70 | 71 | // Register prompt handlers using SDK schemas 72 | this.server.setRequestHandler(ListPromptsRequestSchema, async () => { 73 | return { 74 | prompts: this.registeredPrompts.map(p => ({ 75 | name: p.name, 76 | description: p.description, 77 | schema: p.schema 78 | })) 79 | }; 80 | }); 81 | 82 | this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { 83 | const promptName = request.params?.name; 84 | if (!promptName || typeof promptName !== 'string') { 85 | throw new Error('Prompt name is required'); 86 | } 87 | 88 | const prompt = this.registeredPrompts.find(p => p.name === promptName); 89 | if (!prompt) { 90 | throw new Error(`Unknown prompt: ${promptName}`); 91 | } 92 | 93 | return { 94 | name: prompt.name, 95 | description: prompt.description, 96 | schema: prompt.schema 97 | }; 98 | }); 99 | 100 | // Then register initialization and shutdown handlers 101 | const initializeSchema = z.object({ 102 | method: z.literal('initialize'), 103 | params: z.object({ 104 | capabilities: z.record(z.unknown()) 105 | }) 106 | }); 107 | 108 | const shutdownSchema = z.object({ 109 | method: z.literal('shutdown') 110 | }); 111 | 112 | this.server.setRequestHandler(initializeSchema, async (request) => { 113 | if (!this.isValidCapabilities(request.params.capabilities)) { 114 | throw { 115 | code: -32602, 116 | message: 'Invalid params: capabilities must be an object' 117 | }; 118 | } 119 | return { 120 | protocolVersion: '2024-11-05', 121 | serverInfo: { 122 | name, 123 | version 124 | }, 125 | capabilities: this.capabilities 126 | }; 127 | }); 128 | 129 | this.server.setRequestHandler(shutdownSchema, async () => { 130 | return { success: true }; 131 | }); 132 | } 133 | 134 | private isValidCapabilities(capabilities: unknown): boolean { 135 | return typeof capabilities === 'object' && capabilities !== null && !Array.isArray(capabilities); 136 | } 137 | 138 | private trackConnection(transport: any): void { 139 | const id = `conn_${this.nextConnectionId++}`; 140 | this.connections.set(id, { id, transport, initialized: true }); 141 | console.error(`🔌 New connection established: ${id}`); 142 | } 143 | 144 | private untrackConnection(transport: any): void { 145 | for (const [id, conn] of this.connections.entries()) { 146 | if (conn.transport === transport) { 147 | this.connections.delete(id); 148 | console.error(`🔌 Connection closed: ${id}`); 149 | break; 150 | } 151 | } 152 | } 153 | 154 | getServer(): Server { 155 | return this.server; 156 | } 157 | 158 | getApp(): Express { 159 | return this.app; 160 | } 161 | 162 | getActiveConnections(): number { 163 | return this.connections.size; 164 | } 165 | 166 | async connectStdio(): Promise<void> { 167 | const transport = new StdioServerTransport(); 168 | this.trackConnection(transport); 169 | try { 170 | await this.server.connect(transport); 171 | } catch (error) { 172 | this.untrackConnection(transport); 173 | throw error; 174 | } 175 | } 176 | 177 | async connectSSE(port = 3000, path = '/sse'): Promise<void> { 178 | this.app.get(path, (req, res: Response) => { 179 | const transport = new SSEServerTransport(path, res); 180 | 181 | res.writeHead(200, { 182 | 'Content-Type': 'text/event-stream', 183 | 'Cache-Control': 'no-cache', 184 | 'Connection': 'keep-alive' 185 | }); 186 | 187 | this.trackConnection(transport); 188 | 189 | this.server.connect(transport).catch(error => { 190 | console.error('Failed to connect transport:', error); 191 | this.untrackConnection(transport); 192 | res.end(); 193 | }); 194 | 195 | res.on('close', () => { 196 | this.untrackConnection(transport); 197 | }); 198 | }); 199 | 200 | await new Promise<void>((resolve) => { 201 | this.app.listen(port, () => { 202 | console.error(`Server listening on port ${port}`); 203 | resolve(); 204 | }); 205 | }); 206 | } 207 | 208 | registerPrompt(prompt: PromptDefinition): void { 209 | // Register the execute handler for this specific prompt 210 | const executeSchema = z.object({ 211 | method: z.literal(`prompts/${prompt.name}/execute`), 212 | params: z.object({ 213 | arguments: z.record(z.unknown()) 214 | }) 215 | }); 216 | 217 | this.server.setRequestHandler(executeSchema, async (request, extra) => { 218 | try { 219 | const context: PromptContext = { 220 | workspace: process.env.WORKSPACE_PATH || '', 221 | connectionId: 'default' 222 | }; 223 | 224 | // Execute the prompt handler with the arguments 225 | const result = await prompt.handler(request.params.arguments, context); 226 | console.error(`Executed prompt: ${prompt.name}`); 227 | 228 | // Ensure we have a properly structured response 229 | if (!result?.messages || !Array.isArray(result.messages)) { 230 | throw new Error('Prompt handler must return a messages array'); 231 | } 232 | 233 | return { 234 | messages: result.messages, 235 | metadata: result.metadata || {}, 236 | tools: [] 237 | }; 238 | } catch (error) { 239 | console.error(`Failed to execute prompt ${prompt.name}:`, error); 240 | return { 241 | messages: [{ 242 | role: 'assistant', 243 | content: { 244 | type: 'text', 245 | text: `Error executing prompt ${prompt.name}: ${error instanceof Error ? error.message : String(error)}` 246 | } 247 | }], 248 | metadata: { error: error instanceof Error ? error.message : String(error) }, 249 | tools: [] 250 | }; 251 | } 252 | }); 253 | 254 | // Track the registered prompt 255 | this.registeredPrompts.push(prompt); 256 | console.error(`Registered prompt: ${prompt.name}`); 257 | } 258 | 259 | async initialize(): Promise<void> { 260 | try { 261 | await this.connectStdio(); 262 | console.error('Server initialized successfully'); 263 | } catch (error) { 264 | console.error('Failed to initialize server:', error); 265 | throw error; 266 | } 267 | } 268 | 269 | async start(): Promise<void> { 270 | try { 271 | console.error('Server started successfully'); 272 | } catch (error) { 273 | console.error('Failed to start server:', error); 274 | throw error; 275 | } 276 | } 277 | } ``` -------------------------------------------------------------------------------- /src/tools/projects/__tests__/handlers.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ProjectsAPI } from '@/api/projects.js'; 2 | import { 3 | listProjects, 4 | createProject, 5 | updateProject, 6 | deleteProject 7 | } from '@/tools/projects/handlers.js'; 8 | import { PlaneInstance } from '@/config/plane-config.js'; 9 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 10 | 11 | // Mock ProjectsAPI 12 | vi.mock('@/api/projects.js', () => ({ 13 | ProjectsAPI: vi.fn().mockImplementation((instance) => ({ 14 | instance, 15 | listProjects: vi.fn(), 16 | createProject: vi.fn(), 17 | updateProject: vi.fn(), 18 | deleteProject: vi.fn() 19 | })) 20 | })); 21 | 22 | describe('Project Tool Handlers', () => { 23 | let api: ReturnType<typeof vi.mocked<ProjectsAPI>>; 24 | const mockInstance: PlaneInstance = { 25 | name: 'test', 26 | baseUrl: 'https://test.plane.so', 27 | defaultWorkspace: 'default-workspace', 28 | apiKey: 'test-key' 29 | }; 30 | 31 | beforeEach(() => { 32 | api = new ProjectsAPI(mockInstance) as ReturnType<typeof vi.mocked<ProjectsAPI>>; 33 | }); 34 | 35 | describe('listProjects', () => { 36 | it('should list projects from default workspace', async () => { 37 | const mockProjects = [{ 38 | id: '123e4567-e89b-12d3-a456-426614174000', 39 | name: 'Test Project', 40 | identifier: 'TEST', 41 | description: null, 42 | network: 1, 43 | workspace: '123e4567-e89b-12d3-a456-426614174001', 44 | project_lead: null, 45 | default_assignee: null, 46 | is_member: true, 47 | member_role: 1, 48 | total_members: 1, 49 | total_cycles: 0, 50 | total_modules: 0, 51 | module_view: true, 52 | cycle_view: true, 53 | issue_views_view: true, 54 | page_view: true, 55 | inbox_view: true, 56 | created_at: '2024-01-25T00:00:00Z', 57 | updated_at: '2024-01-25T00:00:00Z', 58 | created_by: '123e4567-e89b-12d3-a456-426614174002', 59 | updated_by: '123e4567-e89b-12d3-a456-426614174002' 60 | }]; 61 | vi.mocked(api.listProjects).mockResolvedValue(mockProjects); 62 | 63 | const result = await listProjects(api, {}); 64 | 65 | expect(api.listProjects).toHaveBeenCalledWith('default-workspace', { include_archived: undefined }); 66 | expect(result.content[0].text).toBe(JSON.stringify(mockProjects, null, 2)); 67 | }); 68 | 69 | it('should list projects from specified workspace', async () => { 70 | const mockProjects = [{ 71 | id: '123e4567-e89b-12d3-a456-426614174000', 72 | name: 'Test Project', 73 | identifier: 'TEST', 74 | description: null, 75 | network: 1, 76 | workspace: '123e4567-e89b-12d3-a456-426614174001', 77 | project_lead: null, 78 | default_assignee: null, 79 | is_member: true, 80 | member_role: 1, 81 | total_members: 1, 82 | total_cycles: 0, 83 | total_modules: 0, 84 | module_view: true, 85 | cycle_view: true, 86 | issue_views_view: true, 87 | page_view: true, 88 | inbox_view: true, 89 | created_at: '2024-01-25T00:00:00Z', 90 | updated_at: '2024-01-25T00:00:00Z', 91 | created_by: '123e4567-e89b-12d3-a456-426614174002', 92 | updated_by: '123e4567-e89b-12d3-a456-426614174002' 93 | }]; 94 | vi.mocked(api.listProjects).mockResolvedValue(mockProjects); 95 | 96 | const result = await listProjects(api, { workspace_slug: 'custom-workspace' }); 97 | 98 | expect(api.listProjects).toHaveBeenCalledWith('custom-workspace', { include_archived: undefined }); 99 | expect(result.content[0].text).toBe(JSON.stringify(mockProjects, null, 2)); 100 | }); 101 | 102 | it('should handle errors gracefully', async () => { 103 | vi.mocked(api.listProjects).mockRejectedValue(new Error('API Error')); 104 | 105 | await expect(listProjects(api, {})) 106 | .rejects 107 | .toThrow('Failed to list projects: API Error'); 108 | }); 109 | }); 110 | 111 | describe('createProject', () => { 112 | it('should create a project in default workspace', async () => { 113 | const mockProject = { 114 | id: '123e4567-e89b-12d3-a456-426614174000', 115 | name: 'New Project', 116 | identifier: 'NEW', 117 | description: null, 118 | network: 1, 119 | workspace: '123e4567-e89b-12d3-a456-426614174001', 120 | project_lead: null, 121 | default_assignee: null, 122 | is_member: true, 123 | member_role: 1, 124 | total_members: 1, 125 | total_cycles: 0, 126 | total_modules: 0, 127 | module_view: true, 128 | cycle_view: true, 129 | issue_views_view: true, 130 | page_view: true, 131 | inbox_view: true, 132 | created_at: '2024-01-25T00:00:00Z', 133 | updated_at: '2024-01-25T00:00:00Z', 134 | created_by: '123e4567-e89b-12d3-a456-426614174002', 135 | updated_by: '123e4567-e89b-12d3-a456-426614174002' 136 | }; 137 | vi.mocked(api.createProject).mockResolvedValue(mockProject); 138 | 139 | const result = await createProject(api, { 140 | name: 'New Project', 141 | identifier: 'NEW' 142 | }); 143 | 144 | expect(api.createProject).toHaveBeenCalledWith('default-workspace', { 145 | name: 'New Project', 146 | identifier: 'NEW' 147 | }); 148 | expect(result.content[0].text).toBe(JSON.stringify(mockProject, null, 2)); 149 | }); 150 | 151 | it('should handle validation errors', async () => { 152 | await expect(createProject(api, {})) 153 | .rejects 154 | .toThrow(); 155 | }); 156 | }); 157 | 158 | describe('updateProject', () => { 159 | it('should update a project', async () => { 160 | const mockProject = { 161 | id: '123e4567-e89b-12d3-a456-426614174000', 162 | name: 'Updated Project', 163 | identifier: 'UPD', 164 | description: null, 165 | network: 1, 166 | workspace: '123e4567-e89b-12d3-a456-426614174001', 167 | project_lead: null, 168 | default_assignee: null, 169 | is_member: true, 170 | member_role: 1, 171 | total_members: 1, 172 | total_cycles: 0, 173 | total_modules: 0, 174 | module_view: true, 175 | cycle_view: true, 176 | issue_views_view: true, 177 | page_view: true, 178 | inbox_view: true, 179 | created_at: '2024-01-25T00:00:00Z', 180 | updated_at: '2024-01-25T00:00:00Z', 181 | created_by: '123e4567-e89b-12d3-a456-426614174002', 182 | updated_by: '123e4567-e89b-12d3-a456-426614174002' 183 | }; 184 | vi.mocked(api.updateProject).mockResolvedValue(mockProject); 185 | 186 | const result = await updateProject(api, { 187 | project_id: '1', 188 | name: 'Updated Project' 189 | }); 190 | 191 | expect(api.updateProject).toHaveBeenCalledWith('default-workspace', '1', { 192 | name: 'Updated Project' 193 | }); 194 | expect(result.content[0].text).toBe(JSON.stringify(mockProject, null, 2)); 195 | }); 196 | 197 | it('should handle missing project_id', async () => { 198 | await expect(updateProject(api, { name: 'Test' })) 199 | .rejects 200 | .toThrow(); 201 | }); 202 | }); 203 | 204 | describe('deleteProject', () => { 205 | it('should delete a project', async () => { 206 | vi.mocked(api.deleteProject).mockResolvedValue(undefined); 207 | 208 | const result = await deleteProject(api, { 209 | project_id: '1' 210 | }); 211 | 212 | expect(api.deleteProject).toHaveBeenCalledWith('default-workspace', '1'); 213 | expect(JSON.parse(result.content[0].text)).toEqual({ 214 | success: true, 215 | message: 'Project deleted successfully' 216 | }); 217 | }); 218 | 219 | it('should handle deletion errors', async () => { 220 | vi.mocked(api.deleteProject).mockRejectedValue(new Error('Not found')); 221 | 222 | await expect(deleteProject(api, { project_id: '1' })) 223 | .rejects 224 | .toThrow('Failed to delete project: Not found'); 225 | }); 226 | }); 227 | }); ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2022", 15 | "lib": ["ES2022"], 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "NodeNext", 29 | "rootDir": "./src", 30 | "moduleResolution": "NodeNext", 31 | "baseUrl": ".", 32 | "paths": { 33 | "@/*": ["src/*"], 34 | "*": ["src/types/*"] 35 | }, 36 | "typeRoots": [ 37 | "./node_modules/@types", 38 | "./src/types" 39 | ], 40 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 41 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 42 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 43 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 44 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 45 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 46 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 47 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 48 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 49 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 50 | "resolveJsonModule": true, 51 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 52 | // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ 53 | 54 | /* JavaScript Support */ 55 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 56 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 57 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 58 | 59 | /* Emit */ 60 | "declaration": true, 61 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 62 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 63 | "sourceMap": true, 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "noEmit": true, /* Disable emitting files from a compilation. */ 66 | // "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. */ 67 | "outDir": "./dist", 68 | // "removeComments": true, /* Disable emitting comments. */ 69 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 70 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 71 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 72 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 73 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 74 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 75 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 76 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 77 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 78 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 79 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 80 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 81 | 82 | /* Interop Constraints */ 83 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 84 | // "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. */ 85 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 86 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 87 | "esModuleInterop": true, 88 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 89 | "forceConsistentCasingInFileNames": true, 90 | 91 | /* Type Checking */ 92 | "strict": true, 93 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 94 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 95 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 96 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 97 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 98 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 99 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 100 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 101 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 102 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 103 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 104 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 105 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 106 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 107 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 108 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 109 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 110 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 111 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 112 | 113 | /* Completeness */ 114 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 115 | "skipLibCheck": true 116 | }, 117 | "include": ["src/**/*"], 118 | "exclude": ["node_modules", "dist"] 119 | } 120 | ``` -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ToolWithClass } from '../types/mcp.js'; 2 | import { ListProjectsTool } from './projects/list.js'; 3 | import { CreateProjectTool } from './projects/create.js'; 4 | import { UpdateProjectTool } from './projects/update.js'; 5 | import { DeleteProjectTool } from './projects/delete.js'; 6 | import { ListIssuesTools } from './issues/list.js'; 7 | import { CreateIssueTool } from './issues/create.js'; 8 | import { GetIssueTool } from './issues/get.js'; 9 | import { UpdateIssueTool } from './issues/update.js'; 10 | 11 | // Export all tools with their classes 12 | export const allTools: ToolWithClass[] = [ 13 | { 14 | name: 'claudeus_plane_projects__list', 15 | description: 'Lists all projects in a Plane workspace. If no workspace is specified, lists projects from the default workspace.', 16 | status: 'enabled', 17 | inputSchema: { 18 | type: 'object', 19 | properties: { 20 | workspace_slug: { 21 | type: 'string', 22 | description: 'The slug of the workspace to list projects from. If not provided, uses the default workspace.' 23 | }, 24 | include_archived: { 25 | type: 'boolean', 26 | description: 'Whether to include archived projects', 27 | default: false 28 | } 29 | } 30 | }, 31 | class: ListProjectsTool 32 | }, 33 | { 34 | name: 'claudeus_plane_projects__create', 35 | description: 'Creates a new project in a workspace. If no workspace is specified, uses the default workspace.', 36 | status: 'enabled', 37 | inputSchema: { 38 | type: 'object', 39 | properties: { 40 | workspace_slug: { 41 | type: 'string', 42 | description: 'The slug of the workspace to create the project in. If not provided, uses the default workspace.' 43 | }, 44 | name: { 45 | type: 'string', 46 | description: 'The name of the project' 47 | }, 48 | identifier: { 49 | type: 'string', 50 | description: 'The unique identifier for the project' 51 | }, 52 | description: { 53 | type: 'string', 54 | description: 'A description of the project' 55 | } 56 | }, 57 | required: ['name', 'identifier'] 58 | }, 59 | class: CreateProjectTool 60 | }, 61 | { 62 | name: 'claudeus_plane_projects__update', 63 | description: 'Updates an existing project in a workspace. If no workspace is specified, uses the default workspace.', 64 | status: 'enabled', 65 | inputSchema: { 66 | type: 'object', 67 | properties: { 68 | workspace_slug: { 69 | type: 'string', 70 | description: 'The slug of the workspace to update the project in. If not provided, uses the default workspace.' 71 | }, 72 | project_id: { 73 | type: 'string', 74 | description: 'The ID of the project to update.' 75 | }, 76 | name: { 77 | type: 'string', 78 | description: 'The new name of the project.' 79 | }, 80 | description: { 81 | type: 'string', 82 | description: 'The new description of the project.' 83 | }, 84 | start_date: { 85 | type: 'string', 86 | format: 'date', 87 | description: 'The new start date of the project.' 88 | }, 89 | end_date: { 90 | type: 'string', 91 | format: 'date', 92 | description: 'The new end date of the project.' 93 | }, 94 | status: { 95 | type: 'string', 96 | description: 'The new status of the project.' 97 | } 98 | }, 99 | required: ['project_id'] 100 | }, 101 | class: UpdateProjectTool 102 | }, 103 | { 104 | name: 'claudeus_plane_projects__delete', 105 | description: 'Deletes an existing project in a workspace. If no workspace is specified, uses the default workspace.', 106 | status: 'enabled', 107 | inputSchema: { 108 | type: 'object', 109 | properties: { 110 | workspace_slug: { 111 | type: 'string', 112 | description: 'The slug of the workspace to delete the project from. If not provided, uses the default workspace.' 113 | }, 114 | project_id: { 115 | type: 'string', 116 | description: 'The ID of the project to delete.' 117 | } 118 | }, 119 | required: ['project_id'] 120 | }, 121 | class: DeleteProjectTool 122 | }, 123 | { 124 | name: 'claudeus_plane_issues__list', 125 | description: 'Lists issues in a Plane project', 126 | status: 'enabled', 127 | inputSchema: { 128 | type: 'object', 129 | properties: { 130 | workspace_slug: { 131 | type: 'string', 132 | description: 'The slug of the workspace to list issues from. If not provided, uses the default workspace.' 133 | }, 134 | project_id: { 135 | type: 'string', 136 | description: 'The ID of the project to list issues from' 137 | }, 138 | state: { 139 | type: 'string', 140 | description: 'Filter issues by state ID' 141 | }, 142 | priority: { 143 | type: 'string', 144 | enum: ['urgent', 'high', 'medium', 'low', 'none'], 145 | description: 'Filter issues by priority' 146 | }, 147 | assignee: { 148 | type: 'string', 149 | description: 'Filter issues by assignee ID' 150 | }, 151 | label: { 152 | type: 'string', 153 | description: 'Filter issues by label ID' 154 | }, 155 | created_by: { 156 | type: 'string', 157 | description: 'Filter issues by creator ID' 158 | }, 159 | start_date: { 160 | type: 'string', 161 | format: 'date', 162 | description: 'Filter issues by start date (YYYY-MM-DD)' 163 | }, 164 | target_date: { 165 | type: 'string', 166 | format: 'date', 167 | description: 'Filter issues by target date (YYYY-MM-DD)' 168 | }, 169 | subscriber: { 170 | type: 'string', 171 | description: 'Filter issues by subscriber ID' 172 | }, 173 | is_draft: { 174 | type: 'boolean', 175 | description: 'Filter draft issues', 176 | default: false 177 | }, 178 | archived: { 179 | type: 'boolean', 180 | description: 'Filter archived issues', 181 | default: false 182 | }, 183 | page: { 184 | type: 'number', 185 | description: 'Page number (1-based)', 186 | default: 1 187 | }, 188 | page_size: { 189 | type: 'number', 190 | description: 'Number of items per page', 191 | default: 100 192 | } 193 | }, 194 | required: ['project_id'] 195 | }, 196 | class: ListIssuesTools 197 | }, 198 | { 199 | name: 'claudeus_plane_issues__create', 200 | description: 'Creates a new issue in a Plane project', 201 | status: 'enabled', 202 | inputSchema: { 203 | type: 'object', 204 | properties: { 205 | workspace_slug: { 206 | type: 'string', 207 | description: 'The slug of the workspace to create the issue in. If not provided, uses the default workspace.' 208 | }, 209 | project_id: { 210 | type: 'string', 211 | description: 'The ID of the project to create the issue in' 212 | }, 213 | name: { 214 | type: 'string', 215 | description: 'The name/title of the issue' 216 | }, 217 | description_html: { 218 | type: 'string', 219 | description: 'The HTML description of the issue' 220 | }, 221 | priority: { 222 | type: 'string', 223 | enum: ['urgent', 'high', 'medium', 'low', 'none'], 224 | description: 'The priority of the issue', 225 | default: 'none' 226 | }, 227 | start_date: { 228 | type: 'string', 229 | format: 'date', 230 | description: 'The start date of the issue (YYYY-MM-DD)' 231 | }, 232 | target_date: { 233 | type: 'string', 234 | format: 'date', 235 | description: 'The target date of the issue (YYYY-MM-DD)' 236 | }, 237 | estimate_point: { 238 | type: 'number', 239 | description: 'Story points or time estimate for the issue' 240 | }, 241 | state: { 242 | type: 'string', 243 | description: 'The state ID for the issue' 244 | }, 245 | assignees: { 246 | type: 'array', 247 | items: { 248 | type: 'string' 249 | }, 250 | description: 'Array of user IDs to assign to the issue' 251 | }, 252 | labels: { 253 | type: 'array', 254 | items: { 255 | type: 'string' 256 | }, 257 | description: 'Array of label IDs to apply to the issue' 258 | }, 259 | parent: { 260 | type: 'string', 261 | description: 'ID of the parent issue (for sub-issues)' 262 | }, 263 | is_draft: { 264 | type: 'boolean', 265 | description: 'Whether this is a draft issue', 266 | default: false 267 | } 268 | }, 269 | required: ['project_id', 'name'] 270 | }, 271 | class: CreateIssueTool 272 | }, 273 | { 274 | name: 'claudeus_plane_issues__get', 275 | description: 'Gets a single issue by ID from a Plane project', 276 | status: 'enabled', 277 | inputSchema: { 278 | type: 'object', 279 | properties: { 280 | workspace_slug: { 281 | type: 'string', 282 | description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' 283 | }, 284 | project_id: { 285 | type: 'string', 286 | description: 'The ID of the project containing the issue' 287 | }, 288 | issue_id: { 289 | type: 'string', 290 | description: 'The ID of the issue to retrieve' 291 | } 292 | }, 293 | required: ['project_id', 'issue_id'] 294 | }, 295 | class: GetIssueTool 296 | }, 297 | { 298 | name: 'claudeus_plane_issues__update', 299 | description: 'Updates an existing issue in a Plane project', 300 | status: 'enabled', 301 | inputSchema: { 302 | type: 'object', 303 | properties: { 304 | workspace_slug: { 305 | type: 'string', 306 | description: 'The slug of the workspace containing the issue. If not provided, uses the default workspace.' 307 | }, 308 | project_id: { 309 | type: 'string', 310 | description: 'The ID of the project containing the issue' 311 | }, 312 | issue_id: { 313 | type: 'string', 314 | description: 'The ID of the issue to update' 315 | }, 316 | name: { 317 | type: 'string', 318 | description: 'The new name/title of the issue' 319 | }, 320 | description_html: { 321 | type: 'string', 322 | description: 'The new HTML description of the issue' 323 | }, 324 | priority: { 325 | type: 'string', 326 | enum: ['urgent', 'high', 'medium', 'low', 'none'], 327 | description: 'The new priority of the issue' 328 | }, 329 | start_date: { 330 | type: 'string', 331 | format: 'date', 332 | description: 'The new start date of the issue (YYYY-MM-DD)' 333 | }, 334 | target_date: { 335 | type: 'string', 336 | format: 'date', 337 | description: 'The new target date of the issue (YYYY-MM-DD)' 338 | }, 339 | estimate_point: { 340 | type: 'number', 341 | description: 'The new story points or time estimate for the issue' 342 | }, 343 | state: { 344 | type: 'string', 345 | description: 'The new state ID for the issue' 346 | }, 347 | assignees: { 348 | type: 'array', 349 | items: { 350 | type: 'string' 351 | }, 352 | description: 'New array of user IDs to assign to the issue' 353 | }, 354 | labels: { 355 | type: 'array', 356 | items: { 357 | type: 'string' 358 | }, 359 | description: 'New array of label IDs to apply to the issue' 360 | }, 361 | parent: { 362 | type: 'string', 363 | description: 'New parent issue ID (for sub-issues)' 364 | }, 365 | is_draft: { 366 | type: 'boolean', 367 | description: 'Whether this issue should be marked as draft' 368 | }, 369 | archived_at: { 370 | type: 'string', 371 | format: 'date-time', 372 | description: 'When to archive the issue (ISO 8601 format)' 373 | }, 374 | completed_at: { 375 | type: 'string', 376 | format: 'date-time', 377 | description: 'When the issue was completed (ISO 8601 format)' 378 | } 379 | }, 380 | required: ['project_id', 'issue_id'] 381 | }, 382 | class: UpdateIssueTool 383 | } 384 | ]; 385 | 386 | // Define tool capabilities 387 | export const toolCapabilities = { 388 | // Projects 389 | claudeus_plane_projects__list: true, 390 | claudeus_plane_projects__get: false, // Coming soon 391 | claudeus_plane_projects__create: true, 392 | claudeus_plane_projects__update: true, 393 | claudeus_plane_projects__delete: true, 394 | 395 | // Issues 396 | claudeus_plane_issues__list: true, 397 | claudeus_plane_issues__get: true, 398 | claudeus_plane_issues__create: true, 399 | claudeus_plane_issues__update: true, 400 | claudeus_plane_issues__delete: false, // Coming soon 401 | 402 | // Cycles (Coming soon) 403 | claudeus_plane_cycles__list: false, 404 | claudeus_plane_cycles__get: false, 405 | claudeus_plane_cycles__create: false, 406 | claudeus_plane_cycles__update: false, 407 | claudeus_plane_cycles__delete: false, 408 | 409 | // Modules (Coming soon) 410 | claudeus_plane_modules__list: false, 411 | claudeus_plane_modules__get: false, 412 | claudeus_plane_modules__create: false, 413 | claudeus_plane_modules__update: false, 414 | claudeus_plane_modules__delete: false 415 | }; ``` -------------------------------------------------------------------------------- /src/prompts/projects/handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { PromptHandler, PromptContext, PromptResponse } from '../../types/prompt.js'; 2 | import { PlaneApiClient, NotificationOptions, ToolExecutionOptions } from '../../api/client.js'; 3 | import { PlaneInstance } from '../../config/plane-config.js'; 4 | 5 | interface Project { 6 | name: string; 7 | total_members: number; 8 | total_cycles: number; 9 | total_modules: number; 10 | [key: string]: any; 11 | } 12 | 13 | interface ProjectMetrics { 14 | name: string; 15 | members: number; 16 | cycles: number; 17 | modules: number; 18 | complexity: number; 19 | } 20 | 21 | interface ProjectStructureAnalysis { 22 | name: string; 23 | missingFeatures: string[]; 24 | cycleGap: number; 25 | moduleGap: number; 26 | memberGap: number; 27 | } 28 | 29 | interface ProjectRecommendation { 30 | project: string; 31 | recommendations: string[]; 32 | } 33 | 34 | export const analyzeWorkspaceHealthHandler: PromptHandler = async (args: Record<string, unknown>, context: PromptContext): Promise<PromptResponse> => { 35 | try { 36 | const planeInstance: PlaneInstance = { 37 | name: 'default', 38 | baseUrl: process.env.PLANE_BASE_URL || '', 39 | apiKey: process.env.PLANE_API_KEY || '' 40 | }; 41 | 42 | const client = new PlaneApiClient(planeInstance, context); 43 | const workspace_slug = args.workspace_slug as string | undefined; 44 | const include_archived = args.include_archived as boolean | undefined; 45 | 46 | // Get projects using the tool 47 | const toolResult = await client.executeTool('claudeus_plane_projects__list', { 48 | workspace_slug, 49 | include_archived 50 | }); 51 | 52 | if (!toolResult?.content?.[0]?.text) { 53 | throw new Error('Invalid tool result format'); 54 | } 55 | 56 | const projects = JSON.parse(toolResult.content[0].text) as Project[]; 57 | 58 | // Analyze project health metrics 59 | const totalProjects = projects.length; 60 | const metrics = { 61 | memberDistribution: projects.map((p: Project) => ({ 62 | name: p.name, 63 | memberCount: p.total_members 64 | })), 65 | cycleUsage: projects.map((p: Project) => ({ 66 | name: p.name, 67 | cycleCount: p.total_cycles 68 | })), 69 | moduleUsage: projects.map((p: Project) => ({ 70 | name: p.name, 71 | moduleCount: p.total_modules 72 | })) 73 | }; 74 | 75 | // Generate insights 76 | const insights: string[] = []; 77 | 78 | // Member distribution insights 79 | const avgMembers = metrics.memberDistribution.reduce((acc: number, p) => acc + p.memberCount, 0) / totalProjects; 80 | insights.push(`Average team size: ${avgMembers.toFixed(1)} members per project`); 81 | 82 | const underStaffed = metrics.memberDistribution.filter(p => p.memberCount < avgMembers * 0.5); 83 | if (underStaffed.length > 0) { 84 | insights.push(`Potentially understaffed projects: ${underStaffed.map(p => p.name).join(', ')}`); 85 | } 86 | 87 | // Feature usage insights 88 | const lowCycleUsage = metrics.cycleUsage.filter(p => p.cycleCount === 0); 89 | if (lowCycleUsage.length > 0) { 90 | insights.push(`Projects not using cycles: ${lowCycleUsage.map(p => p.name).join(', ')}`); 91 | } 92 | 93 | const lowModuleUsage = metrics.moduleUsage.filter(p => p.moduleCount === 0); 94 | if (lowModuleUsage.length > 0) { 95 | insights.push(`Projects not using modules: ${lowModuleUsage.map(p => p.name).join(', ')}`); 96 | } 97 | 98 | const response: PromptResponse = { 99 | messages: [{ 100 | role: 'assistant', 101 | content: { 102 | type: 'text', 103 | text: '# Workspace Health Analysis\n\n' + 104 | `Analyzed ${totalProjects} projects${workspace_slug ? ` in workspace ${workspace_slug}` : ''}\n\n` + 105 | '## Key Insights\n\n' + 106 | insights.map(i => `- ${i}`).join('\n') + '\n\n' + 107 | '## Recommendations\n\n' + 108 | '1. Consider redistributing team members for more balanced project staffing\n' + 109 | '2. Encourage use of cycles for better project planning\n' + 110 | '3. Leverage modules for improved project organization' 111 | } 112 | }], 113 | metadata: { 114 | metrics, 115 | insights 116 | } 117 | }; 118 | 119 | return response; 120 | } catch (error) { 121 | console.error('Failed to analyze workspace health:', error); 122 | return { 123 | messages: [{ 124 | role: 'assistant', 125 | content: { 126 | type: 'text', 127 | text: `Error analyzing workspace health: ${error instanceof Error ? error.message : String(error)}` 128 | } 129 | }], 130 | metadata: { 131 | error: error instanceof Error ? error.message : String(error) 132 | } 133 | }; 134 | } 135 | }; 136 | 137 | export const suggestResourceAllocationHandler: PromptHandler = async (args: Record<string, unknown>, context: PromptContext): Promise<PromptResponse> => { 138 | try { 139 | const planeInstance: PlaneInstance = { 140 | name: 'default', 141 | baseUrl: process.env.PLANE_BASE_URL || '', 142 | apiKey: process.env.PLANE_API_KEY || '' 143 | }; 144 | 145 | const client = new PlaneApiClient(planeInstance, context); 146 | const workspace_slug = args.workspace_slug as string | undefined; 147 | const focus_area = (args.focus_area as string) || 'members'; 148 | 149 | // Get projects using the tool 150 | const toolResult = await client.executeTool('claudeus_plane_projects__list', { 151 | workspace_slug 152 | }); 153 | 154 | if (!toolResult?.content?.[0]?.text) { 155 | throw new Error('Invalid tool result format'); 156 | } 157 | 158 | const projects = JSON.parse(toolResult.content[0].text) as Project[]; 159 | 160 | // Analyze current allocation 161 | const allocation: ProjectMetrics[] = projects.map(p => ({ 162 | name: p.name, 163 | members: p.total_members, 164 | cycles: p.total_cycles, 165 | modules: p.total_modules, 166 | complexity: (p.total_cycles * 0.4) + (p.total_modules * 0.6) // Weighted complexity score 167 | })); 168 | 169 | // Generate recommendations based on focus area 170 | const recommendations: string[] = []; 171 | switch (focus_area) { 172 | case 'members': { 173 | const avgMembers = allocation.reduce((acc: number, p) => acc + p.members, 0) / projects.length; 174 | allocation.forEach(p => { 175 | const recommendedMembers = Math.ceil(p.complexity / avgMembers * p.members); 176 | if (recommendedMembers !== p.members) { 177 | recommendations.push(`${p.name}: ${p.members} → ${recommendedMembers} members (based on complexity)`); 178 | } 179 | }); 180 | break; 181 | } 182 | case 'cycles': { 183 | const avgCycles = allocation.reduce((acc: number, p) => acc + p.cycles, 0) / projects.length; 184 | allocation.forEach(p => { 185 | if (p.cycles < avgCycles * 0.5) { 186 | recommendations.push(`${p.name}: Consider increasing cycle usage (current: ${p.cycles}, avg: ${avgCycles.toFixed(1)})`); 187 | } 188 | }); 189 | break; 190 | } 191 | case 'modules': { 192 | const avgModules = allocation.reduce((acc: number, p) => acc + p.modules, 0) / projects.length; 193 | allocation.forEach(p => { 194 | if (p.modules < avgModules * 0.5) { 195 | recommendations.push(`${p.name}: Consider increasing module usage (current: ${p.modules}, avg: ${avgModules.toFixed(1)})`); 196 | } 197 | }); 198 | break; 199 | } 200 | } 201 | 202 | const response: PromptResponse = { 203 | messages: [{ 204 | role: 'assistant', 205 | content: { 206 | type: 'text', 207 | text: '# Resource Allocation Recommendations\n\n' + 208 | `Focus Area: ${focus_area}\n\n` + 209 | '## Recommendations\n\n' + 210 | recommendations.map(r => `- ${r}`).join('\n') + '\n\n' + 211 | '## Additional Notes\n\n' + 212 | '- Recommendations are based on project complexity and current resource usage\n' + 213 | '- Consider team expertise and project priorities when implementing changes' 214 | } 215 | }], 216 | metadata: { 217 | allocation, 218 | recommendations 219 | } 220 | }; 221 | 222 | return response; 223 | } catch (error) { 224 | console.error('Failed to analyze resource allocation:', error); 225 | return { 226 | messages: [{ 227 | role: 'assistant', 228 | content: { 229 | type: 'text', 230 | text: `Error analyzing resource allocation: ${error instanceof Error ? error.message : String(error)}` 231 | } 232 | }], 233 | metadata: { 234 | error: error instanceof Error ? error.message : String(error) 235 | } 236 | }; 237 | } 238 | }; 239 | 240 | export const recommendProjectStructureHandler: PromptHandler = async (args: Record<string, unknown>, context: PromptContext): Promise<PromptResponse> => { 241 | try { 242 | const planeInstance: PlaneInstance = { 243 | name: 'default', 244 | baseUrl: process.env.PLANE_BASE_URL || '', 245 | apiKey: process.env.PLANE_API_KEY || '' 246 | }; 247 | 248 | const client = new PlaneApiClient(planeInstance, context); 249 | const workspace_slug = args.workspace_slug as string | undefined; 250 | const template_project = args.template_project as string | undefined; 251 | 252 | // Get projects using the tool 253 | const toolResult = await client.executeTool('claudeus_plane_projects__list', { 254 | workspace_slug 255 | }); 256 | 257 | if (!toolResult?.content?.[0]?.text) { 258 | throw new Error('Invalid tool result format'); 259 | } 260 | 261 | const projects = JSON.parse(toolResult.content[0].text) as Project[]; 262 | 263 | // Define best practices 264 | const bestPractices = { 265 | minCycles: 1, 266 | minModules: 2, 267 | minMembers: 2, 268 | recommendedFeatures: ['cycles', 'modules', 'project_lead'] as const 269 | }; 270 | 271 | // If template project specified, use its metrics as best practices 272 | if (template_project) { 273 | const templateData = projects.find((p: Project) => p.name === template_project); 274 | if (templateData) { 275 | bestPractices.minCycles = templateData.total_cycles; 276 | bestPractices.minModules = templateData.total_modules; 277 | bestPractices.minMembers = templateData.total_members; 278 | } 279 | } 280 | 281 | // Analyze each project 282 | const structureAnalysis: ProjectStructureAnalysis[] = projects.map((p: Project) => ({ 283 | name: p.name, 284 | missingFeatures: bestPractices.recommendedFeatures.filter((f) => !p[f]), 285 | cycleGap: Math.max(0, bestPractices.minCycles - p.total_cycles), 286 | moduleGap: Math.max(0, bestPractices.minModules - p.total_modules), 287 | memberGap: Math.max(0, bestPractices.minMembers - p.total_members) 288 | })); 289 | 290 | // Generate recommendations 291 | const recommendations: ProjectRecommendation[] = structureAnalysis 292 | .filter((p) => p.missingFeatures.length > 0 || p.cycleGap > 0 || p.moduleGap > 0 || p.memberGap > 0) 293 | .map((p) => ({ 294 | project: p.name, 295 | recommendations: [ 296 | ...p.missingFeatures.map((f) => `Enable ${f} feature`), 297 | p.cycleGap > 0 ? `Add ${p.cycleGap} more cycle(s)` : null, 298 | p.moduleGap > 0 ? `Add ${p.moduleGap} more module(s)` : null, 299 | p.memberGap > 0 ? `Add ${p.memberGap} more team member(s)` : null 300 | ].filter((rec): rec is string => rec !== null) 301 | })); 302 | 303 | const response: PromptResponse = { 304 | messages: [{ 305 | role: 'assistant', 306 | content: { 307 | type: 'text', 308 | text: '# Project Structure Recommendations\n\n' + 309 | (template_project ? `Using "${template_project}" as template\n\n` : 'Using best practices as template\n\n') + 310 | '## Project-Specific Recommendations\n\n' + 311 | recommendations.map((r) => 312 | `### ${r.project}\n` + 313 | r.recommendations.map((rec) => `- ${rec}`).join('\n') 314 | ).join('\n\n') + '\n\n' + 315 | '## General Guidelines\n\n' + 316 | '- Maintain consistent project structure across workspace\n' + 317 | '- Regularly review and update project organization\n' + 318 | '- Document project structure decisions' 319 | } 320 | }], 321 | metadata: { 322 | bestPractices, 323 | structureAnalysis, 324 | recommendations 325 | } 326 | }; 327 | 328 | return response; 329 | } catch (error) { 330 | console.error('Failed to analyze project structure:', error); 331 | return { 332 | messages: [{ 333 | role: 'assistant', 334 | content: { 335 | type: 'text', 336 | text: `Error analyzing project structure: ${error instanceof Error ? error.message : String(error)}` 337 | } 338 | }], 339 | metadata: { 340 | error: error instanceof Error ? error.message : String(error) 341 | } 342 | }; 343 | } 344 | }; 345 | 346 | export async function handleProjectPrompts( 347 | promptId: string, 348 | args: Record<string, unknown>, 349 | client: PlaneApiClient 350 | ): Promise<PromptResponse> { 351 | try { 352 | switch (promptId) { 353 | case 'analyze_workspace_health': { 354 | const result = await client.listProjects(); 355 | return { 356 | messages: [{ 357 | role: 'assistant', 358 | content: { 359 | type: 'text', 360 | text: JSON.stringify(result, null, 2) 361 | } 362 | }] 363 | }; 364 | } 365 | 366 | case 'suggest_resource_allocation': { 367 | const result = await client.listProjects(); 368 | return { 369 | messages: [{ 370 | role: 'assistant', 371 | content: { 372 | type: 'text', 373 | text: JSON.stringify(result, null, 2) 374 | } 375 | }] 376 | }; 377 | } 378 | 379 | case 'recommend_project_structure': { 380 | const result = await client.listProjects(); 381 | return { 382 | messages: [{ 383 | role: 'assistant', 384 | content: { 385 | type: 'text', 386 | text: JSON.stringify(result, null, 2) 387 | } 388 | }] 389 | }; 390 | } 391 | 392 | default: 393 | throw new Error(`Unknown prompt: ${promptId}`); 394 | } 395 | } catch (error) { 396 | return { 397 | messages: [{ 398 | role: 'assistant', 399 | content: { 400 | type: 'text', 401 | text: `Error: ${error instanceof Error ? error.message : String(error)}` 402 | } 403 | }] 404 | }; 405 | } 406 | } 407 | ```