Thank you for your interest in contributing! This guide covers everything you need to understand the project, set up your environment, and get code merged — without having to spelunk through the source yourself.
- 🐍 Python 3.12+
- ⚡ uv package manager
- 🔧 go-task (strongly recommended — all dev commands use it)
- 🔥 Docker (for local testing with Spark History Server)
- 📦 Node.js (for MCP Inspector testing)
- 🍴 Fork and clone the repository
git clone https://github.com/YOUR_USERNAME/mcp-apache-spark-history-server.git
cd mcp-apache-spark-history-server- 📦 Complete environment setup (installs deps + pre-commit hooks)
task dev-setupWithout go-task:
uv sync --group dev --frozen
uv run pre-commit install- 🚀 Start all development services
task dev-all # Spark History Server + MCP server + MCP InspectorIndividual services:
task start-spark-bg # Spark History Server only (background)
task start-mcp-bg # MCP server only (background)
task start-inspector-bg # MCP Inspector only → http://localhost:6274- 🧹 Stop all background services
task stop-allUnderstanding the module layout saves you from searching for where to make a change.
src/spark_history_mcp/
├── core/ — FastMCP server entry point (main.py) and app/client wiring (app.py)
├── api/ — Spark History Server REST clients
│ ├── base_client.py — shared HTTP session, auth, retry logic
│ ├── spark_client.py — Spark REST API endpoints
│ ├── emr_persistent_ui_client.py — AWS EMR presigned URL + session management
│ ├── spark_html_client.py — HTML scraping fallback
│ └── factory.py — client factory (selects client by server config)
├── tools/ — All 50+ MCP tool implementations
│ ├── tools.py / application.py / analysis.py / jobs_stages.py
│ ├── executors.py / comparisons.py / timelines.py
│ ├── common.py — shared helpers (get_client_or_default, etc.)
│ ├── metrics.py / recommendations.py / matching.py
│ └── comparison_modules/ — comparison sub-system (see deep dive below)
│ ├── core.py — compare_app_performance (orchestrator)
│ ├── stages.py — compare_stages, find_top_stage_differences
│ ├── executors.py — compare_app_executors, executor timelines
│ ├── environment.py — compare_app_environments, compare_app_resources, compare_app_jobs, compare_app_stages_aggregated
│ ├── utils.py — delta calculation, significance filtering, aggregation helpers
│ └── constants.py — SIGNIFICANCE_THRESHOLD, SIMILARITY_THRESHOLD, recommendation thresholds
├── cli/ — Click-based CLI mirroring all MCP tools
│ ├── main.py / session.py / formatters.py
│ └── commands/ — apps, analyze, compare, config, server, cache, cleanup
│ └── formatter_modules/ — human/json/table output renderers
├── prompts/ — 16 intelligent prompt templates for structured analysis
│ ├── performance.py / optimization.py / troubleshooting.py / reporting.py
├── models/ — Pydantic data models
│ ├── spark_types.py — Spark API response types
│ └── mcp_types.py — MCP tool input/output types
├── config/ — YAML config loading with Pydantic Settings + env-var override
├── utils/ — Shared sorting utilities
└── cache.py — Response caching layer
| What you're adding | Where to put it |
|---|---|
| New MCP tool | tools/ — pick the matching category file (analysis.py, jobs_stages.py, etc.) or tools.py |
| New comparison tool | tools/comparison_modules/ — add to the relevant file, orchestrate in core.py |
| New CLI command | cli/commands/ — add to existing group file or create a new one |
| New prompt template | prompts/ — performance.py, optimization.py, troubleshooting.py, or reporting.py |
| New Spark API call | api/spark_client.py (or emr_persistent_ui_client.py for EMR) |
| New data model | models/spark_types.py or models/mcp_types.py |
servers:
local:
default: true
url: "http://localhost:18080"
# Add additional servers here:
production:
url: "https://spark-history.company.com:18080"
verify_ssl: true
auth:
username: ${PROD_USERNAME}
password: ${PROD_PASSWORD}
mcp:
transports:
- stdio # or streamable-http (only one allowed)
port: "18888"
debug: false
address: localhostTools accept an optional server parameter to target a non-default server:
get_application(app_id="spark-abc123", server="production")All config values can be overridden via environment variables — copy .env.example to .env for local overrides.
| Variable | Description |
|---|---|
SHS_MCP_PORT |
MCP server port (default: 18888) |
SHS_MCP_DEBUG |
Enable debug logging (default: false) |
SHS_MCP_ADDRESS |
Bind address (default: localhost) |
SHS_MCP_TRANSPORT |
Transport mode: stdio or streamable-http |
SHS_SERVERS_<NAME>_URL |
Server URL (e.g. SHS_SERVERS_PRODUCTION_URL) |
SHS_SERVERS_<NAME>_AUTH_USERNAME |
Basic auth username |
SHS_SERVERS_<NAME>_AUTH_PASSWORD |
Basic auth password |
SHS_SERVERS_<NAME>_AUTH_TOKEN |
Bearer token |
SHS_SERVERS_<NAME>_VERIFY_SSL |
SSL verification (true/false) |
SHS_SERVERS_<NAME>_EMR_CLUSTER_ARN |
AWS EMR cluster ARN |
Three real Spark event logs are included in the repository root:
| Event Log | Scenario |
|---|---|
spark-bcec39f6201b42b9925124595baad260 |
Successful ETL job |
spark-110be3a8424d4a2789cb88134418217b |
Data processing job |
spark-cc4d115f011443d787f03a71a476a745 |
Multi-stage analytics job |
The start_local_spark_history.sh script starts Spark History Server with these files already loaded.
task test # Unit tests with coverage
task test-verbose # Verbose output
task test-e2e # End-to-end tests (requires running services)Without go-task:
uv run pytest # Unit tests
uv run pytest --cov=. --cov-report=html # With coverage report
uv run pytest tests/unit/test_tools.py -v # Specific file| Layer | Location | Requires services | Command |
|---|---|---|---|
| Unit tests | tests/unit/ |
No (mocked) | task test |
| Integration / E2E | tests/ |
Yes | task test-e2e |
task dev-all
# Opens MCP Inspector at http://localhost:6274
# Call any tool interactively and inspect the exact JSON responseManual start:
npx @modelcontextprotocol/inspector uv run -m spark_history_mcp.core.main| Hook | What it checks |
|---|---|
trailing-whitespace, end-of-file-fixer, mixed-line-ending |
File hygiene (LF line endings) |
check-yaml, check-json, check-toml |
Config file syntax |
check-merge-conflict |
No unresolved merge markers |
check-added-large-files |
Files under 1 MB |
ruff-check --fix + ruff-format |
Python linting and formatting |
bandit |
Security scanning (excludes tests/) |
check-config-security |
Detects hardcoded credentials in YAML |
markdownlint |
Documentation style |
Note: MyPy type checking is currently disabled (tracked as a TODO in
.pre-commit-config.yaml).
task validate # lint + test (what CI requires)
task ci # lint + test + security scan (full local CI)
task lint # Ruff lint only
task lint-fix # Ruff lint with auto-fix
task format # Ruff formatWithout go-task:
uv run ruff check --fix
uv run ruff format
uv run bandit -r . -f json -o bandit-report.json- Formatter/linter: Ruff (
pyproject.toml→[tool.ruff]) - Line length: 88 characters
- Target: Python 3.12+
- Import sorting: Automatic (Ruff rule
I) - Type hints: Encouraged but not yet enforced by MyPy
Three GitHub Actions jobs run on every push and pull request (.github/workflows/ci.yml):
- Code Quality — runs all pre-commit hooks against the full repo
- Test — Ruff lint +
pytestwith coverage on Python 3.12 - Integration — E2E tests against a real Spark History Server (depends on Test passing)
Releases are versioned automatically from git tags via hatch-vcs (configured in pyproject.toml).
- New MCP Tools: Additional Spark analysis tools
- Performance Improvements: Optimize API calls and data processing
- Error Handling: Better error messages and recovery
- Documentation: Examples, tutorials, and guides
- Testing: More comprehensive test coverage
- Monitoring: Metrics and observability features
- Configuration: More flexible configuration options
- CI/CD: GitHub Actions improvements
- AI Agent Examples: New integration patterns
- Deployment: Additional deployment methods
- Analytics: Advanced Spark job analysis tools
- 🌿 Create a feature branch
git checkout -b feature/your-new-feature
git checkout -b fix/bug-description
git checkout -b docs/improve-readme-
💻 Make your changes following existing code style and patterns
-
✅ Test thoroughly
task validate # Required: must pass before opening a PR
task test-e2e # Recommended: run if you changed tool logic
# Also test with MCP Inspector for any new or modified tools- 📤 Submit pull request — use descriptive commit messages, reference related issues, update
CHANGELOG.mdif applicable
Tools are split into category files under tools/. Pick the file that matches your tool's domain:
| File | Domain |
|---|---|
tools/application.py |
Application metadata and overview |
tools/jobs_stages.py |
Job and stage analysis |
tools/executors.py |
Executor and resource utilization |
tools/analysis.py |
SparkInsight intelligence tools |
tools/comparisons.py |
Cross-application comparison (re-exports from comparison_modules/) |
tools/timelines.py |
Timeline and executor timeline tools |
Follow this pattern when implementing a tool:
@mcp.tool()
def your_new_tool(
app_id: str,
server: Optional[str] = None,
# other parameters
) -> Dict[str, Any]:
"""
Brief description of what this tool does.
Args:
app_id: The Spark application ID
server: Optional server name (uses default server if omitted)
Returns:
Description of return value
"""
client = get_client_or_default(mcp.get_context(), server)
return client.your_method(app_id)get_client_or_default lives in tools/common.py and handles server selection and client instantiation.
Add a unit test:
@patch("spark_history_mcp.tools.your_module.get_client_or_default")
def test_your_new_tool(self, mock_get_client):
mock_client = MagicMock()
mock_client.your_method.return_value = {"result": "data"}
mock_get_client.return_value = mock_client
result = your_new_tool("spark-app-123")
self.assertEqual(result, {"result": "data"})
mock_client.your_method.assert_called_once_with("spark-app-123")Also test interactively via MCP Inspector — unit tests alone don't catch serialization or schema issues.
Every MCP tool should also be accessible via the CLI. CLI commands live in cli/commands/ grouped by domain (same grouping as MCP tools). The CLI uses Click and a shared session from cli/session.py.
import click
from cli.session import get_client
@click.command("your-tool")
@click.argument("app_id")
@click.option("--server", default=None)
@click.option("--format", "fmt", type=click.Choice(["human", "json", "table"]), default="human")
def your_tool_cmd(app_id: str, server: str | None, fmt: str) -> None:
"""Brief description shown in --help."""
client = get_client(server)
result = client.your_method(app_id)
# render using the formatter matching `fmt`All three output formats (human, json, table) must be supported. Formatter utilities live in cli/formatter_modules/.
The comparison subsystem is the most complex part of the codebase. This section explains how it works so you can extend it without reading all the source.
The top-level tool (tools/comparison_modules/core.py) orchestrates four sub-comparisons:
compare_app_performance(app_id1, app_id2, top_n=3)
├── fetch app summaries → aggregated_overview.application_summary
├── compare_app_jobs() → aggregated_overview.job_performance
├── compare_app_executors() → aggregated_overview.executor_performance
├── find_top_stage_differences() → stage_deep_dive
├── compare_app_environments() → environment_comparison
└── generate_recommendations() → key_recommendations (priority >= medium, top 5)
Full return shape:
{
"schema_version": 1,
"applications": {
"app1": {"id": str, "name": str},
"app2": {"id": str, "name": str},
},
"aggregated_overview": {
"application_summary": {...},
"job_performance": {...},
"executor_performance": {...},
},
"stage_deep_dive": {
"applications": {...},
"top_stage_differences": [...], # see find_top_stage_differences below
"analysis_parameters": {
"requested_top_n": int,
"similarity_threshold": float,
"available_stages_app1": int,
"available_stages_app2": int,
"matched_stages": int,
},
"stage_summary": {
"matched_stages": int,
"total_time_difference_seconds": float,
"average_time_difference_seconds": float,
"max_time_difference_seconds": float,
},
},
"environment_comparison": {...},
"recommendations": [...],
"key_recommendations": [...] # top 5, priority >= medium
}Every comparison metric is returned in two forms — a human-readable _percent_change string and a machine-readable _ratio float. Both are computed from these helpers in tools/comparison_modules/utils.py:
def _calculate_percentage_change(val1: float, val2: float) -> str:
"""Returns e.g. '+15.3%', 'N/A', '+∞'"""
if val1 == 0:
return "N/A" if val2 == 0 else "+∞"
change = ((val2 - val1) / val1) * 100
return f"{change:+.1f}%"
def calculate_safe_ratio(val1: float, val2: float) -> float:
"""Zero-safe ratio: val2 / val1"""
if val1 == 0:
return float("inf") if val2 > 0 else 1.0
return val2 / val1To suppress noise, metrics are filtered before being returned. The significance_threshold parameter (default defined in tools/comparison_modules/constants.py) controls what's kept:
# _ratio fields: keep if deviation from 1.0 exceeds threshold
if metric.endswith("_ratio") and abs(value - 1.0) >= threshold:
include()
# _percent_change fields: keep if absolute % change exceeds threshold * 100
if metric.endswith("_percent_change") and abs(value) >= threshold * 100:
include()Every response includes a filtering_summary or filtering_info key reporting:
{
"total_metrics": int,
"significant_metrics": int,
"significance_threshold": float,
"filtering_applied": bool,
}tools/comparison_modules/stages.py — compares two stages by explicit ID and returns only the metrics exceeding the threshold:
{
"stage_comparison": {
"stage1": {"app_id": str, "stage_id": int, "name": str, "status": str},
"stage2": {"app_id": str, "stage_id": int, "name": str, "status": str},
},
"significant_differences": {
"stage_metrics": {
"<metric_name>": {
"stage1": float,
"stage2": float,
"change": str, # e.g. "+42.7%"
"significance": float,
}
},
"task_metrics": {
"<metric_name>_median": {
"stage1": float, "stage2": float,
"change": str, "significance": float,
}
},
},
"summary": {
"significance_threshold": float,
"total_differences_found": int,
},
}Stage duration formula:
duration = (stage.completion_time - stage.submission_time).total_seconds()
# Returns 0.0 if either timestamp is missingStages are matched across apps by name similarity (score 0.0–1.0). Only pairs above similarity_threshold are considered. Each entry in top_stage_differences:
{
"stage_name": str,
"similarity_score": float, # 0.0–1.0
"app1_stage": {
"stage_id": int, "name": str, "status": str, "duration_seconds": float
},
"app2_stage": {
"stage_id": int, "name": str, "status": str, "duration_seconds": float
},
"time_difference": {
"absolute_seconds": float,
"percentage": float,
"slower_application": "app1" | "app2",
},
"stage_metrics_comparison": {...},
"executor_analysis": {...},
}Time difference percentage:
time_diff_percent = (abs(d2 - d1) / max(d1, d2)) * 100tools/comparison_modules/environment.py — aggregates all stages per app then compares the totals:
{
"aggregated_stage_metrics": {
"app1": {
"total_stages": int,
"total_input_bytes": int,
"total_output_bytes": int,
"total_shuffle_read_bytes": int,
"total_shuffle_write_bytes": int,
"total_memory_spilled_bytes": int,
"total_disk_spilled_bytes": int,
"total_executor_run_time_ms": int,
"total_executor_cpu_time_ns": int,
"total_gc_time_ms": int,
"avg_stage_duration_ms": float,
"total_tasks": int,
"total_failed_tasks": int,
},
"app2": { ...same keys... },
},
"stage_performance_comparison": {
# Every metric above has a _ratio (float) and _percent_change (str) twin
"total_stages_ratio": float,
"total_stages_percent_change": str,
"total_shuffle_read_bytes_ratio": float,
"total_shuffle_read_bytes_percent_change": str,
# ...
},
"filtering_info": {
"significance_threshold": float,
"show_only_significant": bool,
},
"recommendations": [...]
}All compare functions return recommendations lists with a priority field:
| Priority | Value | Meaning |
|---|---|---|
"critical" |
5 | Must fix |
"high" |
4 | Strongly recommended |
"medium" |
3 | Worth addressing |
"low" |
2 | Minor suggestion |
"info" |
1 | Informational |
compare_app_performance surfaces only priority >= medium in key_recommendations (top 5 after deduplication and sorting).
Recommendation trigger thresholds (from tools/comparison_modules/constants.py):
RECOMMENDATION_CORE_RATIO_THRESHOLD = 1.5 # flag when core count differs by >50%
RECOMMENDATION_MEMORY_RATIO_THRESHOLD = 1.5 # flag when memory differs by >50%
RECOMMENDATION_SPILL_BYTES_THRESHOLD = 100 * 1024 * 1024 # flag when spill > 100 MBInclude:
- Environment: Python version, OS, uv version
- Steps to reproduce: Clear step-by-step instructions
- Expected vs actual behavior: What should happen vs what happens
- Logs: Relevant error messages or logs
- Sample data: Spark application IDs that reproduce the issue (if possible)
Include:
- Use case: Why is this feature needed?
- Proposed solution: How should it work?
- Alternatives: Other approaches considered
- Examples: Sample usage or screenshots
- README.md: Main project overview and quick start
- CONTRIBUTING_TECHNICAL.md: Deep dive into architecture and internal logic
- TESTING.md: Comprehensive testing guide
- docs/mcp-tools.md: Full MCP tool reference
- examples/: AI agent integration examples
- Code comments: Inline documentation for complex logic
- Use emojis consistently for visual appeal
- Include code examples for all features
- Provide screenshots for UI elements
- Keep language clear and beginner-friendly
Contributors are recognized in:
- GitHub Contributors section
- Release notes for significant contributions
- Project documentation for major features
- 💬 Discussions: GitHub Discussions
- 🐛 Issues: GitHub Issues
- 📚 Documentation: Check existing docs first
- 🤝 Be respectful: Treat everyone with kindness and professionalism
- 🎯 Stay on topic: Keep discussions relevant to the project
- 🧠 Be constructive: Provide helpful feedback and suggestions
- 🌍 Be inclusive: Welcome contributors of all backgrounds and skill levels
🎉 Thank you for contributing to Spark History Server MCP!
Your contributions help make Apache Spark monitoring more intelligent and accessible to the community.