Skip to main content
Glama
pfaeffli
by pfaeffli
README.md
# Clockodo MCP Server

MCP server wrapper for the Clockodo time tracking API with configurable feature sets.

[![MCP Badge](https://lobehub.com/badge/mcp/pfaeffli-clockodo-mcp-server)](https://lobehub.com/mcp/pfaeffli-clockodo-mcp-server)
[![Docker Image](https://ghcr-badge.egpl.dev/pfaeffli/clockodo-mcp-server/latest_tag?trim=major&label=latest)](https://github.com/pfaeffli/clockodo-mcp-server/pkgs/container/clockodo-mcp-server)
[![Security Scans](https://img.shields.io/badge/security-scanned-green)](https://github.com/pfaeffli/clockodo-mcp-server/actions)

**🐳 Docker Image:** `ghcr.io/pfaeffli/clockodo-mcp-server:latest`

## Table of Contents
- [Features](#features)
- [Architecture & Patterns](#architecture--patterns)
- [Setup](#setup)
- [Environment Variables](#environment-variables)
- [Available Features](#available-features)
- [Development](#development)
- [Manual Testing](#manual-testing)

## Features

This MCP server provides comprehensive time tracking capabilities through:

- **Tools**: 25+ tools for time tracking, HR analytics, and team management
- **Prompts**: Interactive prompt templates for common workflows
- **Resources**: Real-time access to time entries, customers, and services
- **Role-Based Access**: Configurable permission levels (employee, team_leader, hr_analytics, admin)

## Architecture & Patterns

This project follows specific architectural patterns to maintain clean, testable, and maintainable code.

### 1. **Layered Architecture**

```
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   MCP Server Layer (server.py)     │  ← Tool registration, MCP protocol
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│   Service Layer (services/)         │  ← Business logic, orchestration
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│   Client Layer (client.py)          │  ← HTTP API communication
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│   External API (Clockodo REST API)  │  ← Third-party service
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
```

**Rules:**
- **Server Layer**: Only handles MCP tool registration and protocol. No business logic.
- **Service Layer**: Contains all business logic. Services use clients but never handle MCP directly.
- **Client Layer**: Pure HTTP/API client. No business logic, only request/response handling.
- **Dependencies flow downward only**: Server → Service → Client (never upward)

### 2. **Configuration Management**

**Pattern**: Feature Flags with Environment Variables

```python
# config.py - Central configuration
class ServerConfig:
    hr_readonly: bool = True      # Default safe
    user_read: bool = False        # Opt-in
    admin_edit: bool = False       # Explicit opt-in

    @classmethod
    def from_env(cls) -> "ServerConfig":
        """Load from environment with safe defaults"""
```

**Rules:**
- All configuration comes from environment variables
- Safe defaults (read-only, minimal permissions)
- Preset configurations available (readonly, user, admin)
- No hardcoded credentials or API keys

### 3. **Dependency Injection**

**Pattern**: Constructor Injection

```python
class HRService:
    def __init__(self, client: ClockodoClient):
        """Inject dependencies explicitly"""
        self.client = client

    def check_overtime_compliance(self, year: int) -> dict:
        # Use injected client
        reports = self.client.get_user_reports(year=year)
```

**Rules:**
- Services receive their dependencies through constructors
- Makes testing easy (mock the dependencies)
- Clear dependency graph
- No global state or singletons (except config)

### 4. **Separation of Concerns**

**Pattern**: Single Responsibility Principle

```
client.py          → HTTP communication only
hr_analyzer.py     → Pure data analysis (no I/O)
hr_service.py      → Orchestration (client + analyzer)
hr_tools.py        → MCP tool wrappers (service → MCP)
server.py          → Tool registration
```

**Rules:**
- Each module has ONE clear purpose
- Analyzers are pure functions (input → output, no side effects)
- Services handle orchestration
- Tools are thin wrappers

### 5. **API Version Handling**

**Pattern**: Resource-Specific Versioning

Clockodo uses a resource-specific versioning scheme. This server always targets the most recent stable version for each resource:
- **v4**: Projects, Services, Absences
- **v3**: Users, Customers
- **v2**: Clock, Entries
- **v1**: User Reports (Legacy reports with no newer version available)

**Rules:**
- Base URL is normalized to end with `/api/`
- All client methods explicitly use the required version prefix (e.g., `v3/users`)
- Responses are normalized to maintain internal consistency (e.g., mapping `data` key to resource-specific keys)
- Legacy v1 endpoints are called without a version prefix

### 6. **Error Handling**

**Pattern**: Let Errors Bubble Up with Context

```python
def _request(self, method: str, endpoint: str) -> dict:
    resp = httpx.request(...)
    resp.raise_for_status()  # Let HTTPStatusError bubble up
    return resp.json()
```

**Rules:**
- Don't catch exceptions unless you can handle them
- Use httpx's built-in error handling
- Add context when re-raising
- Let MCP framework handle final error presentation

### 7. **Type Safety**

**Pattern**: Type Hints Everywhere

```python
def check_overtime_compliance(
    self, year: int, max_overtime_hours: float = 80
) -> dict:
    """
    Clear input/output types

    Args:
        year: Year to check (e.g., 2024)
        max_overtime_hours: Maximum allowed overtime hours

    Returns:
        Dictionary with overtime violations
    """
```

**Rules:**
- All functions have type hints
- Use `from __future__ import annotations` for forward references
- Docstrings explain the structure of complex dicts
- mypy validation in CI/CD

### 8. **Testing Strategy**

**Pattern**: Layered Testing

```
Unit Tests          → Pure functions (analyzers)
Integration Tests   → Services with mocked clients
Manual Tests        → Jupyter notebooks for real API
```

**Rules:**
- Mock external HTTP calls (use respx)
- Test business logic in isolation
- Use pytest fixtures for common setup
- Manual testing with real credentials in notebooks

### 9. **Documentation as Code**

**Pattern**: Self-Documenting Code

```python
@mcp.tool()
def check_overtime_compliance(year: int, max_overtime_hours: float = 80) -> dict:
    """
    Check which employees have excessive overtime.

    This docstring becomes the MCP tool description.
    """
```

**Rules:**
- Docstrings on all public functions
- Type hints provide inline documentation
- README explains patterns and architecture
- Examples in manual-test/ folder

### 10. **Environment-Based Behavior**

**Pattern**: Configuration Over Code

```python
# Don't do this:
if production_mode:
    do_something()

# Do this:
config = ServerConfig.from_env()
if config.is_enabled(FeatureGroup.ADMIN_EDIT):
    register_admin_tools()
```

**Rules:**
- Feature flags control behavior
- No if/else for environments in code
- Test different configurations via env vars
- Document all environment variables

### 11. **Project Versioning**

**Pattern**: Automated Git Tag Versioning

The project version is automatically managed using `setuptools-scm` based on Git tags. This ensures that the version in `pyproject.toml` and at runtime always matches the latest Git tag.

**Rules:**
- Version is NOT hardcoded in `pyproject.toml` (uses `dynamic = ["version"]`)
- `src/clockodo_mcp/__init__.py` retrieves the version at runtime using `importlib.metadata` or a generated `_version.py` file
- New releases are created by pushing a signed tag (e.g., `git tag -s v0.3.0`), then publishing the GitHub release for it with `gh release create v0.3.0 --verify-tag`
- Publish the release before the tag's build finishes (about 7 minutes): the `attach-sbom` job uploads the SBOMs to that release. If it ran too early, publish the release and re-run only `attach-sbom`
- The version matches semantic versioning principles

---

## Setup

### Option 1: Using Pre-built Docker Image from GitHub Container Registry

#### For Local MCP Clients (Claude Desktop, IDEs) - stdio transport

Add configuration to your IDE's MCP settings (e.g., Claude Desktop):

```json
{
  "mcpServers": {
    "clockodo": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "-e",
        "CLOCKODO_API_USER=your@email.com",
        "-e",
        "CLOCKODO_API_KEY=your_api_key",
        "-e",
        "CLOCKODO_USER_AGENT=my-company/1.0",
        "-e",
        "CLOCKODO_BASE_URL=https://my.clockodo.com/api/",
        "-e",
        "CLOCKODO_EXTERNAL_APP_CONTACT=dev@company.com",
        "-e",
        "CLOCKODO_MCP_ROLE=employee",
        "ghcr.io/pfaeffli/clockodo-mcp-server:latest"
      ]
    }
  }
}
```

#### For Remote Access (Web Apps) - HTTP/SSE transport

> **āš ļø Note:** SSE transport is currently experimental and has known issues. Not recommended for production use.

```bash
docker run -d \
  -p 127.0.0.1:8000:8000 \
  -e CLOCKODO_API_USER=your@email.com \
  -e CLOCKODO_API_KEY=your_api_key \
  -e CLOCKODO_MCP_ROLE=employee \
  -e CLOCKODO_MCP_TRANSPORT=sse \
  -e CLOCKODO_MCP_HOST=0.0.0.0 \
  -e CLOCKODO_MCP_AUTH_TOKEN=change-me-long-random-secret \
  -e CLOCKODO_MCP_ALLOWED_HOSTS='localhost:*,127.0.0.1:*' \
  -e CLOCKODO_MCP_PORT=8000 \
  ghcr.io/pfaeffli/clockodo-mcp-server:latest
```

Clients must send `Authorization: Bearer <CLOCKODO_MCP_AUTH_TOKEN>`. The port is
published on `127.0.0.1` only; put a TLS-terminating reverse proxy in front for
anything beyond the local machine and add its hostname to
`CLOCKODO_MCP_ALLOWED_HOSTS`.

**Available image tags:**
- `latest` - Latest stable release
- `v1.0.0`, `v1.0`, `v1` - Semantic version tags
- `main-<sha>` - Latest main branch build

### Option 2: Build Locally

1. Build the Docker image:
   ```bash
   make build-mcp
   ```

2. Add configuration to your IDE's MCP settings using `clockodo-mcp:latest` instead of the ghcr.io image.

## Environment Variables

### API Credentials (Required)
- `CLOCKODO_API_USER` - Your Clockodo email
- `CLOCKODO_API_KEY` - Your Clockodo API key

### API Configuration (Optional)
- `CLOCKODO_USER_AGENT` - Custom user agent string (default: "clockodo-mcp/unknown")
- `CLOCKODO_BASE_URL` - API base URL (default: "https://my.clockodo.com/api/")
- `CLOCKODO_EXTERNAL_APP_CONTACT` - Contact info for external app header (default: API user email)

### Time Zone (Optional)
- `CLOCKODO_TIMEZONE` - IANA zone used for times without an offset (default: "Europe/Zurich"); all times are sent to Clockodo as UTC

### Transport Configuration (Optional)
- `CLOCKODO_MCP_TRANSPORT` - Transport protocol (default: "stdio")
  - `stdio` - Standard input/output for local processes (Claude Desktop, IDEs) **[Recommended]**
  - `sse` - HTTP/SSE for remote access **[Experimental - Known Issues]**
- `CLOCKODO_MCP_HOST` - Host address to bind to (default: "127.0.0.1"; use "0.0.0.0" inside Docker)
- `CLOCKODO_MCP_PORT` - Port for SSE transport (default: 8000)
- `CLOCKODO_MCP_ALLOWED_HOSTS` - Comma-separated `Host` header allow-list for DNS-rebinding protection, always enabled (default: "127.0.0.1:*,localhost:*")
- `CLOCKODO_MCP_AUTH_TOKEN` - Bearer token for SSE. **Required** when the host is not loopback (the server refuses to start without it); optional but enforced when set on loopback. Compared in constant time.

Unknown `CLOCKODO_MCP_ROLE` or `CLOCKODO_MCP_TRANSPORT` values abort startup with an error. `clockodo-mcp --version` prints the version and exits.

> **āš ļø SSE Transport Limitation:** The SSE transport is experimental and currently has issues with the MCP library (mcp >= 2.3). The server accepts connections and messages but does not properly send responses back through the event stream, causing client initialization timeouts. **Use stdio transport for production.** SSE support depends on upstream fixes in the MCP library.

### Role Configuration (Recommended)

Use `CLOCKODO_MCP_ROLE` to set the user's role:

```bash
CLOCKODO_MCP_ROLE=employee      # Default - Track your own time
CLOCKODO_MCP_ROLE=team_leader   # Employee + approve vacations & edit team entries
CLOCKODO_MCP_ROLE=hr_analytics  # View HR compliance reports only
CLOCKODO_MCP_ROLE=admin         # Full access to everything
```

| Role | Can Do | Tools |
|------|--------|-------|
| **employee** | Track own time, request vacation | 16 |
| **team_leader** | Everything employee can + see users + approve team vacations + edit team entries | 24 |
| **hr_analytics** | View HR compliance reports (overtime, vacation violations) for all employees | 5 |
| **admin** | Full access to all features (incl. `get_raw_user_reports`) | 28 |

Everything is registered according to the role; tools, resources and prompts outside it do not exist for the client. Write tools carry MCP annotations (`destructiveHint` for edit/delete/approve/reject/adjust, `readOnlyHint` for reads) so clients can ask for confirmation. Tools returning Clockodo free text say so in their description: the text is user-provided data, not instructions.

### Legacy Configuration (Deprecated)

The following are still supported but deprecated. Use `CLOCKODO_MCP_ROLE` instead:

**Legacy Presets:**
- `CLOCKODO_MCP_PRESET=readonly` - Maps to hr_analytics role
- `CLOCKODO_MCP_PRESET=user` - Maps to employee role
- `CLOCKODO_MCP_PRESET=team_leader` - Maps to team_leader role
- `CLOCKODO_MCP_PRESET=admin` - Maps to admin role

**Legacy Granular Flags:**
- `CLOCKODO_MCP_ENABLE_HR_READONLY=true`
- `CLOCKODO_MCP_ENABLE_USER_READ=true`
- `CLOCKODO_MCP_ENABLE_USER_EDIT=true`
- `CLOCKODO_MCP_ENABLE_TEAM_LEADER=true`
- `CLOCKODO_MCP_ENABLE_ADMIN_READ=true`
- `CLOCKODO_MCP_ENABLE_ADMIN_EDIT=true`

## Available Features

### Core Tools
- `health` - Health check (always available; shows enabled features)
- `list_customers`, `list_services`, `list_projects` - Master data (`USER_READ`, `USER_EDIT`, `TEAM_LEADER` or `ADMIN_READ`)
- `list_users` - List all Clockodo users (`TEAM_LEADER`, `HR_READONLY` or `ADMIN_READ`; not for employees)
- `get_raw_user_reports(year)` - Raw API response for debugging (`ADMIN_READ` only)

### Prompts (when `USER_EDIT` enabled)
- `start_tracking` - Start tracking time for a customer and service (uses `start_my_clock`)
- `stop_tracking` - Stop tracking the current time entry (uses `stop_my_clock`)
- `request_vacation` - Request vacation time (uses `add_my_vacation`)

### Resources
- `clockodo://current-entry` - Currently running time entry (`USER_READ`)
- `clockodo://recent-entries` - Recent time entries, last 7 days (`USER_READ`)
- `clockodo://customers`, `clockodo://services`, `clockodo://projects` - Master data (same groups as the `list_*` tools)

### HR Analytics (when `HR_READONLY` enabled)
- `check_overtime_compliance(year, max_overtime_hours)` - Check employee overtime
- `check_vacation_compliance(year, min_vacation_days, max_vacation_remaining)` - Check vacation usage
- `get_hr_summary(year, ...)` - Complete HR compliance report

### User Tools (when `USER_READ` or `USER_EDIT` enabled)
- `get_my_clock()` - Get currently running clock
- `get_my_time_entries(time_since, time_until)` - Get your time entries
- `get_my_absences(year, absence_type=None)` - List your absences for a year (all statuses), including the `id` needed to delete or adjust them
- `start_my_clock(...)` - Start tracking time
- `stop_my_clock()` - Stop tracking time
- `add_my_time_entry(...)` - Add a manual time entry
- `edit_my_time_entry(entry_id, time_since=None, time_until=None, text=None, customers_id=None, services_id=None, projects_id=None, billable=None)` - Edit your time entry; only passed fields change (`billable`: 0/1/2; times as in `add_my_time_entry`)
- `delete_my_time_entry(entry_id)` - Delete your time entry
- `add_my_vacation(date_since, date_until, half_day=False)` - Request vacation; `half_day=True` books a half day (single day only)
- `add_my_sick_day(date_since, date_until, sick_note=False, child=False)` - Report a sick day; `child=True` books a sick day of a child
- `edit_my_vacation(absence_id, date_since=None, date_until=None, half_day=None)` - Change the dates or half-day flag of your absence
- `delete_my_vacation(absence_id)` - Delete an absence; approved absences are withdrawn (cancelled) first

### Team Leader Tools (when `TEAM_LEADER` enabled)
- `list_pending_vacation_requests(year)` - List all pending vacation requests
- `approve_vacation_request(absence_id)` - Approve a vacation request (refused for your own absence)
- `reject_vacation_request(absence_id)` - Reject a vacation request (refused for your own absence)
- `adjust_vacation_dates(absence_id, new_date_since, new_date_until)` - Adjust vacation length (refused for your own absence; use `edit_my_vacation`)
- `create_team_member_vacation(user_id, date_since, date_until, ...)` - Create vacation for team member; pending unless `auto_approve=True` (default `False`, refused for yourself)
- `edit_team_member_entry(entry_id, time_since=None, time_until=None, text=None, customers_id=None, services_id=None, projects_id=None, billable=None)` - Edit a team member's time entry; only passed fields change, the entry can't change user
- `delete_team_member_entry(entry_id)` - Delete team member's time entry

## Development

```bash
# Build
make build-mcp

# Run tests
make test

# Type checking
make type

# Linting
make lint

# Style check
make format-check
```

### Security Scanning

Run comprehensive security scans on the Docker image:

```bash
# Run all security scans (vulnerability, Docker best practices, licenses, SBOM)
make all-scans

# Individual scans
make vulnerability-scan  # Trivy vulnerability scanning
make docker-scan        # Dockle Docker best practices
make license-check      # Python dependency license check
make sbom              # Generate Software Bill of Materials
```

All security tools run via Docker containers - no local installation required.

## Manual Testing

For manual testing with real Clockodo API credentials, use the Jupyter notebook:

```bash
make manual-test
```

Open `http://localhost:8888` and navigate to `work/manual-test/test_clockodo.ipynb`.

See [manual-test/JUPYTER_TESTING.md](manual-test/JUPYTER_TESTING.md) for detailed instructions.

A live end-to-end QA suite against a Clockodo **trial** company is available via `make live-test`; see [manual-test/LIVE_TESTS.md](manual-test/LIVE_TESTS.md) (it writes and deletes data, never use production).

## Project Structure

```
clockodo-mcp/
ā”œā”€ā”€ src/clockodo_mcp/
│   ā”œā”€ā”€ server.py              # MCP tool registration
│   ā”œā”€ā”€ client.py              # Clockodo API client
│   ā”œā”€ā”€ config.py              # Feature flag configuration
│   ā”œā”€ā”€ hr_analyzer.py         # Pure data analysis functions
│   ā”œā”€ā”€ services/
│   │   ā”œā”€ā”€ hr_service.py      # Business logic orchestration
│   │   ā”œā”€ā”€ user_service.py    # User operations
│   │   └── team_leader_service.py  # Team leader operations
│   └── tools/
│       ā”œā”€ā”€ hr_tools.py        # MCP tool wrappers
│       ā”œā”€ā”€ user_tools.py      # User tool wrappers
│       ā”œā”€ā”€ team_leader_tools.py    # Team leader tool wrappers
│       └── debug_tools.py     # Debugging utilities
ā”œā”€ā”€ tests/                      # Unit and integration tests
ā”œā”€ā”€ manual-test/               # Jupyter notebooks for manual testing
ā”œā”€ā”€ docker-compose.yml         # Dev and server services
ā”œā”€ā”€ docker-compose.test.yml    # Test and Jupyter services
└── makefile                   # Build and test targets
```

## Contributing

When adding new features, follow these patterns:

1. **New API Endpoint**: Add method to `client.py`
2. **Business Logic**: Create/update service in `services/`
3. **MCP Tool**: Add tool registration in `server.py`
4. **Tests**: Add unit tests in `tests/`
5. **Documentation**: Update README and docstrings

Always maintain the layered architecture: Server → Service → Client

TDQS

A3.6/5.0

Scored across 16 tools

Disambiguation4/5

Tools target distinct resources and actions (time entries, clock control, absences, reference lists). Some overlap remains between add_my_time_entry and start_my_clock (both create entries) and between add_my_vacation and add_my_sick_day (both add absences), but descriptions clarify boundaries. Overall mostly distinct.

Naming Consistency4/5

Strong verb_noun pattern with my_ for user-specific resources (get_my_*, add_my_*, edit_my_*, delete_my_*, start_my_clock/stop_my_clock) and list_* for global reference data. Minor inconsistencies: health lacks a verb, list_customers/projects/services omit my_ while get_my_absences includes it, and edit/delete_my_vacation actually operate on any absence.

Tool Count4/5

16 tools for a personal time-tracking and absence-management integration is slightly above the ideal 15 but each tool maps to a distinct operation; no redundant tools. Health check is a minor meta addition. Scope is reasonable.

Completeness4/5

Core CRUD for time entries and clock start/stop/get is complete, and absences support add, edit, delete, and list. Notable gap: absence creation covers only vacation and sick day, not special leave (type 2) or overtime reduction (type 3), though those types can be listed and edited/deleted.

Maintenance

ActivityMaintained
ResponsivenessWithin a week