In Part 1, we built a simple MCP server with basic tools. Now let's build something production-ready: a complete Plane API integration with 24 tools for project management.
By the end of this part, you'll have MCP tools for creating issues, managing labels, tracking sprints, and more - all callable by Claude Code.
What is Plane?
Plane is an open-source project management platform (think Jira, Linear). It has a comprehensive REST API perfect for demonstrating real-world MCP patterns.
We'll wrap Plane's API so Claude can:
- List and create projects
- Manage issues and sub-issues
- Work with labels and states
- Track modules (sprints/milestones)
Project Setup
Starting from Part 1's structure:
plane-mcp-server/
├── src/
│ ├── server.py # Main MCP server
│ ├── config.py # Configuration management
│ ├── plane_client.py # Plane API client
│ └── tools/
│ └── plane_tools.py # Tool implementations
├── .env
└── requirements.txt
Dependencies
Update requirements.txt:
mcp>=1.0.0
requests>=2.31.0
python-dotenv>=1.0.0
Configuration Management
Real MCP servers need flexible configuration. Create src/config.py:
"""
Configuration management using environment variables
"""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Application configuration from environment"""
def __init__(self):
# Plane API settings
self.plane_api_url = os.getenv('PLANE_API_URL')
self.plane_api_token = os.getenv('PLANE_API_TOKEN')
self.plane_workspace_slug = os.getenv('PLANE_WORKSPACE_SLUG')
# MCP server settings
self.mcp_transport = os.getenv('MCP_TRANSPORT', 'stdio')
self.mcp_host = os.getenv('MCP_HOST', '0.0.0.0')
self.mcp_port = int(os.getenv('MCP_PORT', '8000'))
# Logging
self.log_level = os.getenv('LOG_LEVEL', 'INFO')
def validate(self) -> bool:
"""Ensure required settings are present"""
required = [
self.plane_api_url,
self.plane_api_token,
self.plane_workspace_slug
]
return all(required)
Environment Configuration
Create .env:
# Plane API Configuration
PLANE_API_URL=https://api.plane.so
PLANE_API_TOKEN=your_plane_api_token_here
PLANE_WORKSPACE_SLUG=your-workspace
# MCP Server Configuration
MCP_TRANSPORT=stdio
LOG_LEVEL=INFO
Getting Plane credentials:
- Sign up at plane.so
- Go to Settings → API Tokens
- Generate a new token
- Find your workspace slug in the URL:
plane.so/your-workspace/...
Building the Plane API Client
Create src/plane_client.py - this handles all HTTP communication with Plane:
"""
Plane API Client with rate limiting and error handling
"""
import requests
import time
from typing import Dict, List, Optional, Any
class PlaneClient:
"""Client for Plane API with rate limiting"""
def __init__(self, api_url: str, api_token: str, workspace_slug: str):
self.base_url = api_url.rstrip('/')
self.workspace_slug = workspace_slug
self.headers = {
'x-api-key': api_token,
'Content-Type': 'application/json'
}
# Rate limiting: 500ms between requests
self.rate_limit_delay = 0.5
self.last_request_time = 0
def _rate_limit(self):
"""Enforce rate limiting between requests"""
elapsed = time.time() - self.last_request_time
if elapsed < self.rate_limit_delay:
time.sleep(self.rate_limit_delay - elapsed)
self.last_request_time = time.time()
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
"""Make rate-limited HTTP request"""
self._rate_limit()
url = f"{self.base_url}/api/v1/{endpoint}"
response = requests.request(method, url, headers=self.headers, **kwargs)
response.raise_for_status()
return response
# Project methods
def get_projects(self) -> List[Dict]:
"""Get all projects in workspace"""
response = self._request('GET', f'workspaces/{self.workspace_slug}/projects/')
return response.json()
def get_project(self, project_id: str) -> Dict:
"""Get single project by ID"""
response = self._request('GET', f'workspaces/{self.workspace_slug}/projects/{project_id}/')
return response.json()
# Issue methods
def get_issues(self, project_id: str, filters: Optional[Dict] = None) -> List[Dict]:
"""Get issues with optional filters"""
params = filters or {}
response = self._request(
'GET',
f'workspaces/{self.workspace_slug}/projects/{project_id}/issues/',
params=params
)
return response.json()
def create_issue(self, project_id: str, data: Dict) -> Dict:
"""Create new issue"""
response = self._request(
'POST',
f'workspaces/{self.workspace_slug}/projects/{project_id}/issues/',
json=data
)
return response.json()
def update_issue(self, project_id: str, issue_id: str, data: Dict) -> Dict:
"""Update existing issue"""
response = self._request(
'PATCH',
f'workspaces/{self.workspace_slug}/projects/{project_id}/issues/{issue_id}/',
json=data
)
return response.json()
# More methods for labels, states, modules, etc.
# See GitHub repo for complete implementation
Key patterns:
- Rate Limiting:
_rate_limit()prevents API abuse - Centralized Headers: Authentication in one place
- Error Propagation:
raise_for_status()bubbles up HTTP errors - Type Hints: Clear return types for IDE support
Organizing Tools by Domain
Create src/tools/plane_tools.py:
"""
Plane MCP Tools - Organized by domain
"""
import json
from typing import Any, Dict
from plane_client import PlaneClient
class PlaneTools:
"""MCP tools for Plane project management"""
def __init__(self, plane_client: PlaneClient):
self.client = plane_client
# ==================== Project Tools ====================
def list_projects(self, arguments: Dict[str, Any]) -> str:
"""List all projects in workspace"""
try:
projects = self.client.get_projects()
return json.dumps({
'count': len(projects),
'projects': [{
'id': p['id'],
'name': p['name'],
'identifier': p['identifier'],
'description': p.get('description', '')
} for p in projects]
}, indent=2)
except Exception as e:
return f"Error listing projects: {str(e)}"
def get_project(self, arguments: Dict[str, Any]) -> str:
"""Get project details by ID or identifier"""
project_id = arguments.get('project_id')
try:
project = self.client.get_project(project_id)
return json.dumps(project, indent=2)
except Exception as e:
return f"Error fetching project: {str(e)}"
# ==================== Issue Tools ====================
def list_issues(self, arguments: Dict[str, Any]) -> str:
"""List issues with optional filters"""
project_id = arguments.get('project_id')
state = arguments.get('state')
priority = arguments.get('priority')
labels = arguments.get('labels', [])
# Build filter object
filters = {}
if state:
filters['state'] = state
if priority:
filters['priority'] = priority
if labels:
filters['labels'] = labels
try:
issues = self.client.get_issues(project_id, filters)
return json.dumps({
'count': len(issues),
'issues': [{
'id': i['id'],
'identifier': i['sequence_id'],
'name': i['name'],
'state': i.get('state_detail', {}).get('name'),
'priority': i.get('priority'),
} for i in issues[:50]] # Limit to 50 for token economy
}, indent=2)
except Exception as e:
return f"Error listing issues: {str(e)}"
def create_issue(self, arguments: Dict[str, Any]) -> str:
"""Create new issue in project"""
project_id = arguments.get('project_id')
title = arguments.get('title')
description = arguments.get('description', '')
priority = arguments.get('priority', 'none')
data = {
'name': title,
'description': description,
'priority': priority
}
try:
issue = self.client.create_issue(project_id, data)
return json.dumps({
'success': True,
'issue_id': issue['id'],
'identifier': issue['sequence_id'],
'message': f"Created issue {issue['sequence_id']}: {title}"
}, indent=2)
except Exception as e:
return f"Error creating issue: {str(e)}"
# ==================== Label Tools ====================
def create_label(self, arguments: Dict[str, Any]) -> str:
"""Create a label in project"""
project_id = arguments.get('project_id')
name = arguments.get('name')
color = arguments.get('color', '#000000')
data = {'name': name, 'color': color}
try:
label = self.client.create_label(project_id, data)
return json.dumps({
'success': True,
'label_id': label['id'],
'message': f"Created label: {name}"
}, indent=2)
except Exception as e:
return f"Error creating label: {str(e)}"
# ... More tools (states, modules, sub-issues, comments)
# See complete code: https://github.com/your-repo
Organization strategy:
- Group related tools with comments
- Consistent error handling pattern
- Return JSON for structured data
- Limit response sizes (note
[:50]slice)
Registering Tools in MCP Server
Update src/server.py:
"""
Plane MCP Server - Main entry point
"""
from mcp.server import FastMCP
from config import Config
from plane_client import PlaneClient
from tools.plane_tools import PlaneTools
# Initialize
config = Config()
if not config.validate():
raise ValueError("Missing required configuration. Check .env file.")
mcp = FastMCP("Plane MCP Server")
# Initialize clients
plane_client = PlaneClient(
api_url=config.plane_api_url,
api_token=config.plane_api_token,
workspace_slug=config.plane_workspace_slug
)
plane_tools = PlaneTools(plane_client)
# ==================== Project Tools ====================
@mcp.tool()
def plane_list_projects() -> str:
"""List all projects in workspace"""
return plane_tools.list_projects({})
@mcp.tool()
def plane_get_project(project_id: str) -> str:
"""Get project details by ID or identifier"""
return plane_tools.get_project({'project_id': project_id})
# ==================== Issue Tools ====================
@mcp.tool()
def plane_list_issues(
project_id: str,
state: str = None,
priority: str = None,
labels: list[str] = None
) -> str:
"""
List issues in a project with optional filters.
Args:
project_id: Project ID or identifier
state: Filter by state (backlog, todo, in_progress, done, cancelled)
priority: Filter by priority (urgent, high, medium, low, none)
labels: Filter by label names
"""
return plane_tools.list_issues({
'project_id': project_id,
'state': state,
'priority': priority,
'labels': labels or []
})
@mcp.tool()
def plane_create_issue(
project_id: str,
title: str,
description: str = "",
priority: str = "none"
) -> str:
"""
Create a new issue in project.
Args:
project_id: Project ID or identifier
title: Issue title
description: Issue description (markdown supported)
priority: urgent, high, medium, low, or none
"""
return plane_tools.create_issue({
'project_id': project_id,
'title': title,
'description': description,
'priority': priority
})
# ... Register 20 more tools (see GitHub for complete code)
# Start server
if __name__ == "__main__":
mcp.run()
Registration patterns:
- Clear tool names:
plane_list_issues(namespace prefix) - Rich docstrings: AI reads these to decide when to use tools
- Type hints: FastMCP validates parameters
- Optional parameters: Use defaults for flexibility
Complete Tool Inventory
Here are all 24 Plane tools we've built:
Project Tools (2)
plane_list_projects- List all projectsplane_get_project- Get project details
Issue Tools (6)
plane_list_issues- List with filtersplane_get_issue- Get issue detailsplane_create_issue- Create new issueplane_update_issue- Update fieldsplane_update_issue_status- Change stateplane_add_comment- Add comment
Sub-Issue Tools (4)
plane_create_sub_issue- Create child issueplane_get_child_issues- List sub-issuesplane_link_as_child- Link existing as childplane_get_parent_issue- Get parent
State Tools (2)
plane_list_states- List workflow statesplane_get_state_by_name- Find by name
Label Tools (3)
plane_list_labels- List project labelsplane_create_label- Create new labelplane_add_label_to_issue- Tag issue
Module Tools (3)
plane_list_modules- List modules/sprintsplane_create_module- Create new moduleplane_link_to_module- Add issue to module
See complete implementation: GitHub Repository
Testing with Claude Code
Update ~/.claude.json:
{
"mcpServers": {
"plane": {
"command": "python3",
"args": ["-m", "src.server"],
"cwd": "/full/path/to/plane-mcp-server",
"env": {
"PLANE_API_URL": "https://api.plane.so",
"PLANE_API_TOKEN": "your_token",
"PLANE_WORKSPACE_SLUG": "your-workspace",
"MCP_TRANSPORT": "stdio"
}
}
}
}
Restart Claude Code, then try:
List all my Plane projects
Claude will discover plane_list_projects and call it:
{
"count": 3,
"projects": [
{
"id": "abc123",
"name": "Portfolio Website",
"identifier": "PORT",
"description": "Personal portfolio site"
},
...
]
}
More examples:
Create a new issue in the PORT project:
Title: Fix mobile responsive header
Priority: high
Description: Header menu overlaps on mobile screens
Show me all urgent issues in the backend project
Common Patterns
Pattern 1: Resolving Identifiers
Users might refer to projects by name or identifier, not internal IDs:
def _resolve_project(self, project_ref: str) -> Dict:
"""Resolve project by ID, identifier, or name"""
projects = self.client.get_projects()
# Try exact ID match
for p in projects:
if p['id'] == project_ref:
return p
# Try identifier (e.g., "PORT")
for p in projects:
if p['identifier'].upper() == project_ref.upper():
return p
# Try name match
for p in projects:
if p['name'].lower() == project_ref.lower():
return p
raise ValueError(f"Project not found: {project_ref}")
Pattern 2: Filtering Data
Return only what's useful to AI:
# Bad: Return everything (wastes tokens)
return json.dumps(issues)
# Good: Return curated fields
return json.dumps({
'count': len(issues),
'issues': [{
'id': i['id'],
'title': i['name'],
'state': i['state_detail']['name'],
'priority': i['priority']
} for i in issues]
}, indent=2)
Pattern 3: Error Messages
Return actionable error messages:
try:
issue = self.client.create_issue(project_id, data)
return json.dumps({'success': True, ...})
except requests.HTTPError as e:
if e.response.status_code == 404:
return f"Error: Project '{project_id}' not found"
elif e.response.status_code == 401:
return "Error: Invalid API token. Check PLANE_API_TOKEN in .env"
else:
return f"API error: {e.response.status_code} - {e.response.text}"
except Exception as e:
return f"Unexpected error: {str(e)}"
What We've Built
You now have:
✅ Production Plane API client with rate limiting ✅ 24 MCP tools across 6 domains ✅ Configuration management via environment variables ✅ Proper error handling and filtering ✅ Claude Code integration
Try this: Ask Claude to help you plan a sprint by creating a module and adding issues to it!
Next: Advanced Features
In Part 3, we'll add powerful capabilities:
- Intelligence Tools: Analyze ticket complexity, recommend splitting
- Git Integration: 8 tools for Git operations
- GitHub Integration: Create PRs, manage issues
- Dependency Parsing: Topological sort for task ordering
→ Continue to Part 3: Advanced Features