Thank you for your interest in contributing to the Redmine MCP Server! This guide will help you get started.
- Report bugs: Submit detailed issue reports
- Suggest features: Propose new features or improvements
- Fix issues: Submit pull requests for bug fixes
- Add features: Implement new functionality
- Improve docs: Enhance documentation and examples
- Write tests: Add test coverage
- Python 3.10+ installed
- Git installed
- Access to a Redmine instance (for testing)
- Familiarity with MCP (Model Context Protocol)
-
Fork and Clone
# Fork the repository on GitHub first git clone https://github.com/YOUR_USERNAME/redmine-mcp-server.git cd redmine-mcp-server
-
Create Virtual Environment
# Using uv (recommended) uv venv source .venv/bin/activate # Or using standard Python python -m venv .venv source .venv/bin/activate
-
Install Development Dependencies
# For source installation uv pip install -e .[dev] # Or using pip pip install -e .[dev]
-
Configure Environment
# Copy example environment file cp .env.example .env # Edit .env with your Redmine credentials # Required: REDMINE_URL, REDMINE_API_KEY (or REDMINE_USERNAME/PASSWORD)
-
Verify Setup
# Run tests to ensure everything works python tests/run_tests.py --all # Start the server uv run python -m redmine_mcp_server.main
After v2.0, the codebase is organized by resource:
Tools live under src/redmine_mcp_server/tools/, one file per Redmine resource:
| File | Tools |
|---|---|
tools/projects.py |
Project listing, versions, members, roles, modules (9 tools) |
tools/issues.py |
Issues, search, copy, delete, relations, watchers, notes, categories, subtasks, private notes (13 tools) |
tools/time_tracking.py |
Time entries, activities, bulk import (4 tools) |
tools/wiki.py |
Wiki page CRUD + rename (1 tool, 6 actions) |
tools/files.py |
File upload/download/delete + attachment URLs (4 tools, plus cleanup_attachment_files admin-gated) |
tools/enumeration.py |
Trackers, statuses, priorities, users, queries (6 tools) |
tools/search.py |
Global search across resources (1 tool) |
tools/checklists.py |
RedmineUP Checklists plugin (2 tools, gated) |
tools/gantt.py |
Gantt chart composite read tool (1 tool) |
tools/products.py |
RedmineUP Products plugin (1 tool, gated) |
tools/contacts.py |
RedmineUP CRM plugin (1 tool, gated) |
tools/documents.py |
DMSF plugin documents (1 tool with list/get/create/update actions, gated) |
tools/meta.py |
Server introspection: get_mcp_server_info (1 tool, always available) |
Total: 45 MCP tools unconditionally registered, plus 1 admin-gated (cleanup_attachment_files, enabled by REDMINE_MCP_EXPOSE_ADMIN_TOOLS=true) for a maximum of 46.
Each tools/<resource>.py also owns its resource-specific serializers (_X_to_dict helpers).
Cross-cutting utilities live as flat private modules:
| Module | Responsibility |
|---|---|
_client.py |
Redmine connection (legacy, oauth, and oauth-proxy), module-level config, logger |
_errors.py |
_handle_redmine_error, _scrub_error_message, _READ_ONLY_ERROR |
_validation.py |
Input validators (_is_positive_int, _is_valid_project_id, _validate_hours) |
_serialization.py |
wrap_insecure_content, _safe_isoformat, _iter_capped, _named_ref, _coerce_json_safe |
_env.py |
Environment accessors: read-only / plugin flags (_is_read_only_mode, _is_*_enabled), secret resolution with Docker/Kubernetes *_FILE support (get_secret, get_required, get_required_secret), require_introspection_credentials, get_allowed_client_redirect_uris (oauth-proxy redirect-URI allowlist), get_health_introspection_ttl_seconds |
_custom_fields.py |
Custom-field parsing, autofill, and update coercion |
_ssrf.py |
SSRF protection for upload_file's source_url |
_cleanup.py |
Background cleanup task |
_http_routes.py |
Starlette routes (/health with a Doorkeeper introspection probe in oauth / oauth-proxy modes and a Redmine credential probe in legacy mode, /files/{id}, /cleanup/status) |
_decorators.py |
@action_dispatch decorator + ActionMode enum |
_auth.py |
RedmineAuthProvider (a RemoteAuthProvider subclass) and its build_remote_auth() factory: composes IntrospectionTokenVerifier (RFC 7662) and adds the RFC 8414 AS-metadata mirror plus the RFC 7009 /revoke route. Used by oauth mode. |
_oauth_proxy.py |
build_oauth_proxy() factory: a FastMCP OAuthProxy backed by IntrospectionTokenVerifier, proxying /authorize, /token, and /revoke to Doorkeeper with external consent and a loopback-default redirect-URI allowlist. Used by oauth-proxy mode. |
_mount.py |
Public base-URL helpers (mcp_base_url, mcp_path_for_http_app, mcp_mount_prefix) for serving the authenticated app behind REDMINE_MCP_BASE_URL. |
_tool_error_middleware.py |
FastMCP middleware that surfaces tool-validation errors with a clean payload. |
oauth_scopes.py |
READ_SCOPES / WRITE_SCOPES inventory + advertised_scopes() used by both the protected-resource and AS-metadata discovery documents. |
The 9 manage_X tools (plus manage_redmine_version) follow a consistent pattern via the @action_dispatch decorator. Example:
from .._decorators import ActionMode, action_dispatch
# Per-action handlers (private async functions in the same file)
async def _list_widgets_action(project_id=None, **_):
# validation, fetch, return
...
async def _create_widget_action(project_id=None, name=None, **_):
# validation, create, return
...
@mcp.tool()
@action_dispatch({
"list": ActionMode.READ,
"create": ActionMode.WRITE,
})
async def manage_widget(action: str, project_id=None, name=None):
"""Docstring with full param/return shape."""
return {
"list": _list_widgets_action,
"create": _create_widget_action,
}The decorator handles:
- Action validation (returns
{"error": "Invalid action ..."}on bad input) - Read-only guard for
WRITEactions (returns_READ_ONLY_ERRORif env enables read-only mode) _ensure_cleanup_started()forWRITEactions- Routing to the per-action handler
Per-action handlers stay responsible for: their own parameter validation, calling the Redmine API, and wrapping exceptions via _handle_redmine_error.
Important: keep the public manage_X tool's full explicit parameter list (FastMCP rejects **kwargs in tool signatures). Only the body changes to return the handler-map dict.
For plugin-gated tools (manage_product, manage_contact), wrap the dispatcher in a feature-flag check:
@mcp.tool()
async def manage_widget(action: str, project_id=None, name=None):
if not _is_widgets_enabled():
return dict(_WIDGETS_DISABLED_ERROR)
return await _manage_widget_dispatch(
action,
project_id=project_id,
name=name,
)
@action_dispatch({...})
async def _manage_widget_dispatch(action, **kwargs):
return {...}# Create a feature branch
git checkout -b feature/your-feature-name
# Or a bug fix branch
git checkout -b fix/issue-description- Write clear, maintainable code
- Follow existing code style and patterns
- Add docstrings to new functions/classes
- Update relevant documentation
Test Types:
- Unit Tests: Test individual functions with mocks
- Integration Tests: Test with actual Redmine server
- Security Tests: Test input validation and security
Running Tests:
# All tests
python tests/run_tests.py --all
# Unit tests only (no external dependencies)
python tests/run_tests.py
# Integration tests (requires Redmine server)
python tests/run_tests.py --integration
# With coverage report
python tests/run_tests.py --coverageLive OAuth Integration Tests (v2.1+):
The unit suite mocks Doorkeeper at the httpx transport boundary. To exercise real Doorkeeper RFC 7662 introspection against a sandbox Redmine:
-
Register an MCP introspection client in the sandbox per
docs/oauth-setup.mdStep 2. -
Mint a valid bearer for any user-flow OAuth app in the same sandbox.
-
Add the four env vars to your
.envfile:REDMINE_URL=https://sandbox-redmine.example.com REDMINE_INTROSPECT_CLIENT_ID=... REDMINE_INTROSPECT_CLIENT_SECRET=... REDMINE_OAUTH_TEST_TOKEN=...
The OAuth integration test module calls
load_dotenv()at import time, so vars in.envare picked up automatically — no need to re-export on the command line. -
Run the full integration suite:
python tests/run_tests.py --integration
…or run just the OAuth subset (needs direct pytest because
run_tests.pydoes not forward-k):python -m pytest tests/test_oauth_integration.py -v -m integration
If any required env var is missing, the OAuth tests skip with a clear "Live OAuth integration not configured" message — safe to leave in CI.
The destructive test_revoked_token_rejected test invalidates the test bearer and is skipped by default. To enable (and lose the bearer):
RUN_DESTRUCTIVE_TESTS=1 python tests/run_tests.py --integrationRe-mint the test bearer through the sandbox's OAuth user-flow before re-running.
Writing Tests:
# Example unit test
@pytest.mark.asyncio
async def test_list_projects():
"""Test listing projects with mocked Redmine client."""
# Test implementation
pass
# Example integration test
@pytest.mark.integration
@pytest.mark.asyncio
async def test_list_projects_integration():
"""Test listing projects with real Redmine server."""
# Test implementation
passPEP 8 Compliance:
# Check compliance
uv run flake8 src/ --max-line-length=88
# Auto-format code
uv run black src/ --line-length=88
# Verify formatting without changes
uv run black --check src/Code Style Guidelines:
- Maximum line length: 88 characters (Black's default)
- Use type hints where appropriate
- Follow PEP 8 naming conventions
- Write descriptive variable and function names
Commit Message Format:
Follow conventional commits:
type: brief description
Detailed explanation (optional)
- List of changes
- Additional context
Types:
feat: New featurefix: Bug fixdocs: Documentation changestest: Adding or updating testsrefactor: Code refactoringchore: Maintenance tasks
Examples:
# Feature commit
git commit -m "feat: add support for custom fields in issues"
# Bug fix commit
git commit -m "fix: resolve authentication error with API key"
# Documentation commit
git commit -m "docs: update installation instructions for Python 3.10"Important:
- Do NOT include Claude Code attribution in commit messages
- Do NOT append "Generated with [Claude Code]" or "Co-Authored-By: Claude"
- Keep commit messages clean and focused on actual changes
# Push your branch
git push origin feature/your-feature-name
# Create pull request on GitHub
# Fill in the PR template with:
# - Description of changes
# - Related issue numbers
# - Testing performed
# - Screenshots (if applicable)# Good: Clear function with type hints and docstring
async def get_issue(issue_id: int, include_journals: bool = True) -> Dict[str, Any]:
"""
Retrieve detailed information about a Redmine issue.
Args:
issue_id: The ID of the issue to retrieve
include_journals: Whether to include journal entries
Returns:
Dictionary containing issue details
Raises:
ValueError: If issue_id is invalid
"""
# Implementation
pass# Good: Proper error handling with user-friendly messages
try:
issue = redmine.issue.get(issue_id)
return _issue_to_dict(issue)
except Exception as e:
return {"error": f"Failed to retrieve issue {issue_id}: {str(e)}"}# Good: MCP tool with clear documentation
@mcp.tool()
async def tool_name(param: str) -> Dict[str, Any]:
"""
Brief description of what this tool does.
Args:
param: Description of parameter
Returns:
Description of return value
"""
# Implementation
pass# tests/test_example.py
import pytest
from unittest.mock import Mock, patch
@pytest.mark.asyncio
async def test_function_success():
"""Test successful execution."""
# Arrange
mock_data = {"id": 1, "name": "test"}
# Act
result = await function_to_test(mock_data)
# Assert
assert result["id"] == 1
assert result["name"] == "test"
@pytest.mark.asyncio
async def test_function_error():
"""Test error handling."""
# Test error scenarios
pass- Aim for >80% code coverage
- Test both success and error paths
- Test edge cases and boundary conditions
- Mock external dependencies in unit tests
- Adding new features or tools
- Changing existing functionality
- Fixing bugs that affect usage
- Adding new configuration options
README.md- Keep concise with references to detailed docsdocs/tool-reference.md- Tool usage detailsdocs/troubleshooting.md- Common issues and solutionsdocs/contributing.md- This fileCHANGELOG.md- Version history
- Use clear, concise language
- Include code examples
- Add links to related documentation
- Keep formatting consistent
- ✅ All tests pass locally
- ✅ Code formatted with Black
- ✅ Flake8 checks pass
- ✅ Documentation updated
- ✅ CHANGELOG.md updated (for features/fixes)
- ✅ Commit messages follow conventions
## Description
Brief description of changes
## Related Issue
Fixes #123
## Changes Made
- List of changes
- Additional context
## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests pass
- [ ] Manual testing completed
## Checklist
- [ ] Code follows style guidelines
- [ ] Documentation updated
- [ ] Tests added/updated
- [ ] CHANGELOG updated- Automated checks run (CI/CD)
- Maintainer reviews code
- Address review feedback
- Approval and merge
Important: All solutions must work with both deployment methods:
uv run python -m redmine_mcp_server.main- Uses
.envfor configuration - For development and debugging
docker-compose up- Uses
.env.dockerfor configuration - For production deployments
Always test both deployment methods before submitting!
Maintainers follow this process for releases:
- Update version in
pyproject.toml - Update
CHANGELOG.md - Update
server.json - Create git tag:
git tag -a vX.Y.Z -m "Release vX.Y.Z" - Push tag:
git push origin vX.Y.Z - GitHub Actions automatically publishes to PyPI
- Create GitHub Release with notes
See RELEASE_SOP.md for complete release procedures.
- Be respectful and professional
- Welcome newcomers
- Provide constructive feedback
- Focus on the issue, not the person
- GitHub Issues: Bug reports and feature requests
- Pull Requests: Code contributions
- Discussions: General questions and ideas
- Check documentation
- Search existing issues
- Review troubleshooting guide
- Ask in GitHub Discussions
redmine-mcp-server/
├── src/redmine_mcp_server/
│ ├── main.py # Entry point; build_authenticated_app() mounts the MCP app + discovery routes (oauth / oauth-proxy)
│ ├── server.py # Owns the shared `mcp = FastMCP(...)` instance; _select_auth_provider() picks the auth provider
│ ├── tools/ # 13 per-resource tool modules (45 MCP tools + 1 admin-gated)
│ ├── _auth.py # RedmineAuthProvider (introspection + AS-metadata + revoke), oauth mode
│ ├── _oauth_proxy.py # OAuthProxy factory (DCR + authorize/token/revoke proxy), oauth-proxy mode
│ ├── _mount.py # Public base-URL / MCP-path / mount-prefix helpers
│ ├── _client.py # Redmine connection (legacy singleton; per-request bearer for oauth / oauth-proxy)
│ ├── _errors.py # Exception → user-friendly dict
│ ├── _validation.py # Input validators
│ ├── _serialization.py # Serializer helpers + `wrap_insecure_content`
│ ├── _env.py # Environment-flag accessors
│ ├── _custom_fields.py # Custom-field parsing/coercion
│ ├── _ssrf.py # SSRF protection for upload_file source_url
│ ├── _cleanup.py # Background attachment cleanup task
│ ├── _http_routes.py # Starlette routes (/health w/ introspection + legacy redmine probe, /files, /cleanup/status)
│ ├── _decorators.py # `@action_dispatch` decorator + `ActionMode` enum
│ ├── _tool_error_middleware.py # FastMCP middleware that normalizes tool validation errors
│ ├── oauth_scopes.py # READ_SCOPES / WRITE_SCOPES inventory + advertised_scopes()
│ └── file_manager.py # Attachment file storage manager
├── tests/ # Comprehensive test suite
├── docs/ # Documentation
│ ├── tool-reference.md # Tool usage documentation
│ ├── troubleshooting.md # Troubleshooting guide
│ ├── oauth-setup.md # OAuth2 multi-tenant setup walkthrough
│ └── contributing.md # This file
├── .env.example # Environment configuration template
├── Dockerfile # Container configuration
├── docker-compose.yml # Multi-container setup
└── pyproject.toml # Project configuration
main.py: Entry point. In an authenticated mode (oauthoroauth-proxy),build_authenticated_app()mounts the FastMCP app under theREDMINE_MCP_BASE_URLpath prefix and adds the provider'sget_well_known_routes()(discovery) plus/health,/files,/cleanup/status; in legacy mode it returnsmcp.http_app(stateless_http=True). Tool registration is triggered viafrom . import tools. No Starlette middleware is added; auth lives inside FastMCP via theauth=constructor parameter.server.py: Owns the sharedmcp = FastMCP("redmine_mcp_tools", auth=...)instance imported by every tool module._select_auth_provider(auth_mode)returnsbuild_remote_auth()(aRedmineAuthProvider) foroauth,build_oauth_proxy()(a FastMCPOAuthProxy) foroauth-proxy, andNonefor legacy._auth.py(oauthmode):build_remote_auth()returns aRedmineAuthProvider, aRemoteAuthProvidersubclass that composesIntrospectionTokenVerifier(RFC 7662 against Doorkeeper's/oauth/introspect) and additionally serves the RFC 8414 AS-metadata mirror and the RFC 7009/revokeroute. ReadsREDMINE_INTROSPECT_CLIENT_ID/_SECRETvia_env.require_introspection_credentials()(fail-fast on startup)._oauth_proxy.py(oauth-proxymode):build_oauth_proxy()returns a FastMCPOAuthProxythat makes the MCP server the OAuth authorization server for clients (DCR +/authorize//token//register) and proxies upstream to Redmine/Doorkeeper, validating tokens with the sameIntrospectionTokenVerifier. Keeps consent external (require_authorization_consent="external"), requiresREDMINE_MCP_JWT_SIGNING_KEY, and restricts client redirect URIs to loopback by default (get_allowed_client_redirect_uris()).tools/: Per-resource tool modules. Each file owns its@mcp.tool()definitions and resource-specific serializers (_X_to_dicthelpers). See Where things live earlier in this guide for the full table.- Flat
_X.pymodules: Cross-cutting helpers (_client,_errors,_validation,_serialization,_env,_custom_fields,_ssrf,_cleanup,_http_routes,_decorators,_auth,_oauth_proxy,_mount,_tool_error_middleware). See Where things live for responsibilities. _client.py: Inoauthandoauth-proxymodes, builds a per-requestRedmine(...)from the bearer returned byfastmcp.server.dependencies.get_access_token(). In legacy mode, caches a singleton built fromREDMINE_API_KEYorREDMINE_USERNAME/REDMINE_PASSWORD. (Pre-v2.1: validated tokens viaGET /users/current.jsonthrough a customContextVar-based middleware; both removed in the v2.1 native-auth migration.)oauth_scopes.py: Single source of truth forscopes_supportedin the protected-resource and AS-metadata discovery documents. FiltersWRITE_SCOPESout whenREDMINE_MCP_READ_ONLY=true.file_manager.py: Attachment file storage manager (UUID-based files + metadata.json with expiry).
This layout was introduced in v2.0 (replacing the previous monolithic redmine_handler.py), updated in v2.1 (auth moved from oauth_middleware.py to native FastMCP auth= via _auth.py), and extended in v2.3 with the oauth-proxy mode (_oauth_proxy.py, _mount.py, and the RemoteAuthProvider → RedmineAuthProvider refactor).
- FastMCP: MCP protocol implementation with HTTP transport
- python-redmine: Official Redmine Python library
- Starlette: ASGI HTTP framework
- uvicorn: ASGI server
- Async/await for non-blocking operations
- Error handling with user-friendly error dictionaries
- Per-resource serializer helpers (
_issue_to_dict,_project_to_dict, etc.) @action_dispatchdecorator formanage_Xtools (action validation, read-only guard, cleanup hook)- Environment-based configuration with
.envfiles
To add a new MCP tool to the server:
-
Pick the right
tools/<resource>.pyfile (or create a new one if the resource doesn't fit any existing module). See Where things live for the file/resource mapping. -
Define the tool in that file:
from ..server import mcp from .._errors import _handle_redmine_error @mcp.tool() async def your_new_tool(param: str) -> Dict[str, Any]: """ Brief description of what this tool does. Args: param: Description of the parameter Returns: Dictionary with results or error information """ try: # Your implementation here result = perform_operation(param) return {"success": True, "data": result} except Exception as e: return _handle_redmine_error(e, "your_new_tool")
-
The tool is automatically registered — FastMCP discovers tools decorated with
@mcp.tool()once the module is imported. Newtools/<resource>.pyfiles must be imported fromtools/__init__.py. -
For
manage_X-style tools (multi-action CRUD), use the@action_dispatchdecorator. See Adding a newmanage_Xtool earlier in this guide. -
Test your tool:
- Add unit tests in
tests/test_<resource>_tools.py(or the matching existing file) - Add integration tests if it interacts with Redmine
- Run tests:
python tests/run_tests.py --all
- Add unit tests in
-
Document your tool:
- Add entry to
docs/tool-reference.md - Include parameters, returns, and examples
- Update README tool count if needed
- Add entry to
- Open an issue for questions
- Check existing documentation
- Review similar contributions
- Ask maintainers for guidance
Thank you for contributing to Redmine MCP Server! 🎉