diff --git a/README.md b/README.md index 1f07a91..ef5bdea 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A modern **TypeScript MCP server** for Basecamp 3, providing seamless integration with Claude Desktop and Cursor IDE through the Model Context Protocol. Built with the official @modelcontextprotocol/sdk and ready for NPX installation. -✅ **TypeScript-First:** Modern, type-safe implementation with full async/await support -🚀 **NPX Ready:** Install and run with `npx @basecamp/mcp-server` -⚡ **46 API Tools:** Complete Basecamp 3 integration with all major features +✅ **TypeScript-First:** Modern, type-safe implementation with full async/await support +🚀 **NPX Ready:** Install and run with `npx @basecamp/mcp-server` +⚡ **51 API Tools:** Complete Basecamp 3 integration with all major features including full TODO CRUD operations ## Quick Setup @@ -22,10 +22,45 @@ npx jhliberty/basecamp-mcp-server config claude # For Claude Desktop npx jhliberty/basecamp-mcp-server config cursor # For Cursor IDE ``` +### Claude Code CLI + +Add the Basecamp MCP server to Claude Code CLI with a single command: + +```bash +claude mcp add --transport stdio basecamp -- npx -y jhliberty/basecamp-mcp-server +``` + +**With environment variables:** +```bash +claude mcp add --transport stdio basecamp \ + --env BASECAMP_ACCOUNT_ID=your_account_id \ + -- npx -y jhliberty/basecamp-mcp-server +``` + +**For team/project shared use:** +```bash +claude mcp add --transport stdio basecamp --scope project \ + -- npx -y jhliberty/basecamp-mcp-server +``` + +**Verify installation:** +```bash +# List all configured MCP servers +claude mcp list + +# Get details for the Basecamp server +claude mcp get basecamp +``` + +Once installed, ask Claude Code questions like: +- "What are all my Basecamp projects?" +- "Show me the to-do lists in project X" +- "Create a new todo in the Marketing project" + ### Prerequisites - **Node.js 18+** (required for ES modules) -- A Basecamp 3 account +- A Basecamp 3 account - A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations) ## Local Development Setup @@ -77,7 +112,7 @@ npx jhliberty/basecamp-mcp-server config cursor # For Cursor IDE 7. **Verify in your AI assistant:** - **Cursor**: Go to Settings → MCP, look for "basecamp" with a green checkmark - **Claude Desktop**: Look for tools icon (🔍) in chat interface - - Available tools: **46 tools** for complete Basecamp control + - Available tools: **51 tools** for complete Basecamp control ### Test Your Setup @@ -119,7 +154,7 @@ Based on the [official MCP quickstart guide](https://modelcontextprotocol.io/qui 4. **Verify in Claude Desktop:** - Look for the "Search and tools" icon (🔍) in the chat interface - - You should see "basecamp" listed with all 46 tools available + - You should see "basecamp" listed with all 51 tools available - Toggle the tools on to enable Basecamp integration ### Claude Desktop Configuration @@ -149,7 +184,10 @@ Example configuration generated: Ask Claude things like: - "What are my current Basecamp projects?" - "Show me the latest campfire messages from the Technology project" -- "Create a new card in the Development column with title 'Fix login bug'" +- "Create a new todo list called 'Sprint Planning' in the Development project" +- "Create a new todo 'Fix login bug' with due date next Friday" +- "Update the todo to change the due date to next Monday" +- "Mark the todo as complete" - "Get all todo items from the Marketing project" - "Search for messages containing 'deadline'" @@ -176,7 +214,12 @@ Once configured, you can use these tools in Cursor: - `get_projects` - Get all Basecamp projects - `get_project` - Get details for a specific project - `get_todolists` - Get todo lists for a project +- `create_todolist` - Create a new todo list in a project - `get_todos` - Get todos from a todo list +- `create_todo` - Create a new todo with assignees, due dates, and descriptions +- `update_todo` - Update an existing todo (content, description, assignees, dates) +- `complete_todo` - Mark a todo as complete +- `uncomplete_todo` - Mark a completed todo as incomplete - `search_basecamp` - Search across projects, todos, and messages - `get_comments` - Get comments for a Basecamp item - `get_campfire_lines` - Get recent messages from a Basecamp campfire @@ -226,6 +269,10 @@ Once configured, you can use these tools in Cursor: Ask Cursor things like: - "Show me all my Basecamp projects" - "What todos are in project X?" +- "Create a new todo list called 'Feature Development'" +- "Add a todo 'Implement user authentication' with due date December 20th" +- "Update the todo description to include implementation details" +- "Mark the authentication todo as complete" - "Search for messages containing 'deadline'" - "Get details for the Technology project" - "Show me the card table for project X" @@ -241,7 +288,7 @@ Ask Cursor things like: The project uses the **official @modelcontextprotocol/sdk** for maximum reliability and compatibility: -1. **MCP Server** (`src/index.ts`) - Official MCP SDK with 46 tools, compatible with both Cursor and Claude Desktop +1. **MCP Server** (`src/index.ts`) - Official MCP SDK with 51 tools, compatible with both Cursor and Claude Desktop 2. **OAuth App** (`src/lib/oauth-app.ts`) - Handles OAuth 2.0 flow with Basecamp 3. **Token Storage** (`src/lib/token-storage.ts`) - Securely stores OAuth tokens 4. **Basecamp Client** (`src/lib/basecamp-client.ts`) - Basecamp API client library diff --git a/package-lock.json b/package-lock.json index 1d5c904..cc1aa65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@basecamp/mcp-server", + "name": "basecamp-mcp-server", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@basecamp/mcp-server", + "name": "basecamp-mcp-server", "version": "1.0.0", "license": "MIT", "dependencies": { diff --git a/src/index.ts b/src/index.ts index 77b0109..ebb435e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,6 +160,19 @@ class BasecampMCPServer { required: ['project_id'], }, }, + { + name: 'create_todolist', + description: 'Create a new todo list in a project', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'The project ID' }, + name: { type: 'string', description: 'The todo list name/title' }, + description: { type: 'string', description: 'Optional todo list description' }, + }, + required: ['project_id', 'name'], + }, + }, { name: 'get_todos', description: 'Get todos from a todo list', @@ -172,6 +185,66 @@ class BasecampMCPServer { required: ['project_id', 'todolist_id'], }, }, + { + name: 'create_todo', + description: 'Create a new todo in a todo list', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project ID' }, + todolist_id: { type: 'string', description: 'The todo list ID' }, + content: { type: 'string', description: 'The todo content/title' }, + description: { type: 'string', description: 'Optional todo description' }, + assignee_ids: { type: 'array', items: { type: 'string' }, description: 'Array of person IDs to assign' }, + due_on: { type: 'string', description: 'Optional due date (ISO 8601 format)' }, + starts_on: { type: 'string', description: 'Optional start date (ISO 8601 format)' }, + notify: { type: 'boolean', description: 'Whether to notify assignees (default: false)' }, + }, + required: ['project_id', 'todolist_id', 'content'], + }, + }, + { + name: 'update_todo', + description: 'Update an existing todo', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project ID' }, + todo_id: { type: 'string', description: 'The todo ID' }, + content: { type: 'string', description: 'The todo content/title' }, + description: { type: 'string', description: 'The todo description' }, + assignee_ids: { type: 'array', items: { type: 'string' }, description: 'Array of person IDs to assign' }, + due_on: { type: 'string', description: 'Due date (ISO 8601 format)' }, + starts_on: { type: 'string', description: 'Start date (ISO 8601 format)' }, + notify: { type: 'boolean', description: 'Whether to notify assignees (default: false)' }, + }, + required: ['project_id', 'todo_id'], + }, + }, + { + name: 'complete_todo', + description: 'Mark a todo as complete', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project ID' }, + todo_id: { type: 'string', description: 'The todo ID' }, + }, + required: ['project_id', 'todo_id'], + }, + }, + { + name: 'uncomplete_todo', + description: 'Mark a todo as incomplete', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project ID' }, + todo_id: { type: 'string', description: 'The todo ID' }, + }, + required: ['project_id', 'todo_id'], + }, + }, // Card Table tools { @@ -590,6 +663,24 @@ class BasecampMCPServer { }; } + case 'create_todolist': { + const todolist = await client.createTodoList( + typedArgs.project_id, + typedArgs.name, + typedArgs.description + ); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'success', + todolist, + message: `Todo list '${typedArgs.name}' created successfully` + }, null, 2) + }] + }; + } + case 'get_todos': { const todos = await client.getTodos(typedArgs.project_id, typedArgs.todolist_id); return { @@ -604,6 +695,78 @@ class BasecampMCPServer { }; } + case 'create_todo': { + const todo = await client.createTodo( + typedArgs.project_id, + typedArgs.todolist_id, + typedArgs.content, + typedArgs.description, + typedArgs.assignee_ids, + typedArgs.due_on, + typedArgs.starts_on, + typedArgs.notify || false + ); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'success', + todo, + message: `Todo '${typedArgs.content}' created successfully` + }, null, 2) + }] + }; + } + + case 'update_todo': { + const todo = await client.updateTodo( + typedArgs.project_id, + typedArgs.todo_id, + typedArgs.content, + typedArgs.description, + typedArgs.assignee_ids, + typedArgs.due_on, + typedArgs.starts_on, + typedArgs.notify || false + ); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'success', + todo, + message: 'Todo updated successfully' + }, null, 2) + }] + }; + } + + case 'complete_todo': { + await client.completeTodo(typedArgs.project_id, typedArgs.todo_id); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'success', + message: 'Todo marked as complete' + }, null, 2) + }] + }; + } + + case 'uncomplete_todo': { + await client.uncompleteTodo(typedArgs.project_id, typedArgs.todo_id); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'success', + message: 'Todo marked as incomplete' + }, null, 2) + }] + }; + } + case 'get_card_table': { const cardTable = await client.getCardTable(typedArgs.project_id); const cardTableDetails = await client.getCardTableDetails(typedArgs.project_id, cardTable.id); diff --git a/src/lib/basecamp-client.ts b/src/lib/basecamp-client.ts index 1a1495f..728c842 100644 --- a/src/lib/basecamp-client.ts +++ b/src/lib/basecamp-client.ts @@ -112,6 +112,78 @@ export class BasecampClient { return response.data; } + async createTodoList( + projectId: string, + name: string, + description?: string + ): Promise { + // First get the project to find the todoset + const project = await this.getProject(projectId); + const todoset = project.dock.find(item => item.name === 'todoset'); + + if (!todoset) { + throw new Error(`No todoset found for project ${projectId}`); + } + + const data: any = { name }; + if (description) data.description = description; + + const response = await this.client.post(`/buckets/${projectId}/todosets/${todoset.id}/todolists.json`, data); + return response.data; + } + + async createTodo( + projectId: string, + todolistId: string, + content: string, + description?: string, + assigneeIds?: string[], + dueOn?: string, + startsOn?: string, + notify = false + ): Promise { + const data: any = { content }; + if (description) data.description = description; + if (assigneeIds && assigneeIds.length > 0) data.assignee_ids = assigneeIds; + if (dueOn) data.due_on = dueOn; + if (startsOn) data.starts_on = startsOn; + if (notify) data.notify = notify; + + const response = await this.client.post(`/buckets/${projectId}/todolists/${todolistId}/todos.json`, data); + return response.data; + } + + async updateTodo( + projectId: string, + todoId: string, + content?: string, + description?: string, + assigneeIds?: string[], + dueOn?: string, + startsOn?: string, + notify = false + ): Promise { + const data: any = {}; + if (content) data.content = content; + if (description) data.description = description; + if (assigneeIds) data.assignee_ids = assigneeIds; + if (dueOn) data.due_on = dueOn; + if (startsOn) data.starts_on = startsOn; + if (notify) data.notify = notify; + + const response = await this.client.put(`/buckets/${projectId}/todos/${todoId}.json`, data); + return response.data; + } + + async completeTodo(projectId: string, todoId: string): Promise { + const response = await this.client.post(`/buckets/${projectId}/todos/${todoId}/completion.json`); + return response.data; + } + + async uncompleteTodo(projectId: string, todoId: string): Promise { + await this.client.delete(`/buckets/${projectId}/todos/${todoId}/completion.json`); + } + // Card Table methods async getCardTables(projectId: string): Promise { const project = await this.getProject(projectId); diff --git a/src/test/utils.ts b/src/test/utils.ts index 826c40b..3d93210 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -2,10 +2,10 @@ import { vi } from 'vitest'; import type { AxiosResponse } from 'axios'; import type { BasecampProject, - BasecampTodo, - BasecampCardTable, - BasecampCard, - oAuthTokens, + Todo, + CardTable, + Card, + OAuthTokens, APIResponse } from '../types/basecamp.js'; @@ -43,7 +43,7 @@ export const mockBasecampProject = (): BasecampProject => ({ ] }); -export const mockBasecampTodo = (): BasecampTodo => ({ +export const mockBasecampTodo = (): Todo => ({ id: '54321', title: 'Test Todo', description: 'A test todo item', @@ -65,7 +65,7 @@ export const mockBasecampTodo = (): BasecampTodo => ({ } }); -export const mockBasecampCardTable = (): BasecampCardTable => ({ +export const mockBasecampCardTable = (): CardTable => ({ id: '98765', name: 'card_table', title: 'Card Table', @@ -78,7 +78,7 @@ export const mockBasecampCardTable = (): BasecampCardTable => ({ lists: [] }); -export const mockBasecampCard = (): BasecampCard => ({ +export const mockBasecampCard = (): Card => ({ id: '13579', title: 'Test Card', content: 'Test card content', @@ -100,7 +100,7 @@ export const mockBasecampCard = (): BasecampCard => ({ } }); -export const mockOAuthToken = (): oAuthTokens => ({ +export const mockOAuthToken = (): OAuthTokens => ({ access_token: 'test_access_token_123', token_type: 'Bearer', expires_in: 7200, @@ -198,7 +198,7 @@ export const mockHttpServer = () => { const handler = handlers[`${method.toUpperCase()}:${path}`]; if (handler) { const mockReq = { query, body }; - const mockRes: APIResponse = { + const mockRes: any = { redirect: vi.fn(), send: vi.fn(), json: vi.fn(), diff --git a/tsconfig.json b/tsconfig.json index 810db71..e4e57d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "exclude": [ "node_modules", "dist", - "**/*.test.ts" + "**/*.test.ts", + "src/test/**/*" ] }