Building an MCP Server - Part 2: Plane API Integration

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:

  1. Sign up at plane.so
  2. Go to Settings → API Tokens
  3. Generate a new token
  4. 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:

  1. Rate Limiting: _rate_limit() prevents API abuse
  2. Centralized Headers: Authentication in one place
  3. Error Propagation: raise_for_status() bubbles up HTTP errors
  4. 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:

  1. Clear tool names: plane_list_issues (namespace prefix)
  2. Rich docstrings: AI reads these to decide when to use tools
  3. Type hints: FastMCP validates parameters
  4. 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 projects
  • plane_get_project - Get project details

Issue Tools (6)

  • plane_list_issues - List with filters
  • plane_get_issue - Get issue details
  • plane_create_issue - Create new issue
  • plane_update_issue - Update fields
  • plane_update_issue_status - Change state
  • plane_add_comment - Add comment

Sub-Issue Tools (4)

  • plane_create_sub_issue - Create child issue
  • plane_get_child_issues - List sub-issues
  • plane_link_as_child - Link existing as child
  • plane_get_parent_issue - Get parent

State Tools (2)

  • plane_list_states - List workflow states
  • plane_get_state_by_name - Find by name

Label Tools (3)

  • plane_list_labels - List project labels
  • plane_create_label - Create new label
  • plane_add_label_to_issue - Tag issue

Module Tools (3)

  • plane_list_modules - List modules/sprints
  • plane_create_module - Create new module
  • plane_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

Resources