diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0ae0fc --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ============================================================================== +# OpenProject Docker & MCP Server Environment Configuration +# ============================================================================== + +# OpenProject Docker Image Tag (Targeting OpenProject 17) +TAG=17-slim + +# Local exposed port on the host +PORT=8080 + +# Database Configuration +POSTGRES_VERSION=17 +POSTGRES_PASSWORD=p4ssw0rd + +# OpenProject Application Configuration +OPENPROJECT_HOST__NAME=localhost:8080 +OPENPROJECT_HTTPS=false +SECRET_KEY_BASE=openproject-secret-key-base-for-local-testing-at-least-64-characters-long + +# MCP Server Client Configuration +# Once the container is running and seeded via scripts/seed.sh, +# OPENPROJECT_API_KEY will be automatically written to .env.test +OPENPROJECT_BASE_URL=http://localhost:8080 +OPENPROJECT_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e8e733 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +# OpenProject MCP Companion - Local Development & Testing Stack +# Target: OpenProject 17 +# Usage: docker compose up -d + +networks: + openproject-net: + driver: bridge + +volumes: + pgdata: + opdata: + +x-op-restart-policy: &restart_policy + restart: unless-stopped + +x-op-image: &image + image: openproject/openproject:${TAG:-17-slim} + +x-op-app: &app + <<: [*image, *restart_policy] + environment: + OPENPROJECT_HTTPS: "${OPENPROJECT_HTTPS:-false}" + OPENPROJECT_HOST__NAME: "${OPENPROJECT_HOST__NAME:-localhost:8080}" + OPENPROJECT_ADDITIONAL__HOST__NAMES: "${OPENPROJECT_ADDITIONAL__HOST__NAMES:-web}" + OPENPROJECT_HSTS: "false" + OPENPROJECT_RAILS__CACHE__STORE: "memcache" + RAILS_CACHE_STORE: "memcache" + OPENPROJECT_CACHE__MEMCACHE__SERVER: "cache:11211" + DATABASE_URL: "${DATABASE_URL:-postgres://postgres:p4ssw0rd@db/openproject?pool=20&encoding=unicode&reconnect=true}" + RAILS_MIN_THREADS: ${RAILS_MIN_THREADS:-4} + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-16} + SECRET_KEY_BASE: "${SECRET_KEY_BASE:-openproject-secret-key-base-for-local-testing-at-least-64-characters-long}" + IMAP_ENABLED: "false" + OPENPROJECT_ENABLE__INTERNAL__ASSETS__SERVER: "true" + volumes: + - opdata:/var/openproject/assets + - ./scripts:/scripts:ro + networks: + - openproject-net + +services: + db: + image: postgres:${POSTGRES_VERSION:-17} + <<: *restart_policy + stop_grace_period: "3s" + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-p4ssw0rd} + POSTGRES_DB: openproject + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d openproject"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - openproject-net + + cache: + image: memcached:alpine + <<: *restart_policy + networks: + - openproject-net + + seeder: + <<: *app + command: "./docker/prod/seeder" + restart: on-failure + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + + web: + <<: *app + command: "./docker/prod/web" + ports: + - "${PORT:-8080}:8080" + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + seeder: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health_checks/default"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 45s + + worker: + <<: *app + command: "./docker/prod/worker" + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + seeder: + condition: service_completed_successfully diff --git a/scripts/seed-test-data.rb b/scripts/seed-test-data.rb new file mode 100644 index 0000000..353a0e4 --- /dev/null +++ b/scripts/seed-test-data.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +# OpenProject MCP Companion - Test Data & API Key Seeder +# Run inside Rails environment via: bundle exec rails runner /scripts/seed-test-data.rb + +require 'json' + +puts "[Seeder] Starting OpenProject MCP test data initialization..." + +# 1. Ensure admin user is active and has no password change requirement +admin = User.find_by(login: 'admin') +if admin.nil? + puts "[Seeder] ERROR: Default admin user not found in database!" + exit 1 +end + +admin.status = Principal.statuses[:active] if defined?(Principal) && Principal.respond_to?(:statuses) +admin.password = 'admin12345678' +admin.password_confirmation = 'admin12345678' +admin.force_password_change = false if admin.respond_to?(:force_password_change=) +admin.save(validate: false) +puts "[Seeder] Admin user configured (login: admin)." + +# 2. Generate or refresh personal API token for admin +Token::API.where(user: admin).destroy_all +api_token = Token::API.create_and_return_value(admin) +puts "[Seeder] Generated new API token for admin." + +# 3. Create or find dedicated MCP test project +test_project = Project.find_or_initialize_by(identifier: 'mcp-test-project') +test_project.name = 'MCP Test Project' +test_project.description = 'Dedicated test project for openproject-mcp companion server.' +test_project.public = true +test_project.workspace_type = 'project' if test_project.respond_to?(:workspace_type=) +test_project.types = Type.all if Type.any? +test_project.save!(validate: false) +puts "[Seeder] Ensured project 'MCP Test Project' (ID: #{test_project.id}, identifier: #{test_project.identifier})." + +# 4. Ensure admin has project manager role in the test project +role = Role.find_by(name: 'Project admin') || Role.find_by(name: 'Manager') || Role.first +if role + membership = Member.find_or_initialize_by(project: test_project, principal: admin) + membership.roles = [role] unless membership.roles.include?(role) + membership.save!(validate: false) + puts "[Seeder] Granted admin '#{role.name}' role in '#{test_project.identifier}'." +end + +# 5. Retrieve taxonomy types, statuses, and priorities +task_type = Type.find_by(name: 'Task') || Type.first +bug_type = Type.find_by(name: 'Bug') || Type.second || task_type +feature_type = Type.find_by(name: 'Feature') || Type.third || task_type + +status_new = Status.find_by(name: 'New') || Status.first +status_in_progress = Status.find_by(name: 'In progress') || Status.second || status_new +status_closed = Status.find_by(name: 'Closed') || Status.last || status_new + +priority_normal = IssuePriority.find_by(name: 'Normal') || IssuePriority.first +priority_high = IssuePriority.find_by(name: 'High') || IssuePriority.second || priority_normal +priority_immediate = IssuePriority.find_by(name: 'Immediate') || IssuePriority.last || priority_normal + +# 6. Seed sample work packages +sample_packages = [ + { + subject: 'Implement MCP Server Core Protocol', + type: task_type, + status: status_in_progress, + priority: priority_high, + description: 'Build stdio transport and wire up tool handlers using @modelcontextprotocol/sdk.' + }, + { + subject: 'Fix HAL+JSON query filter URL encoding', + type: bug_type, + status: status_new, + priority: priority_immediate, + description: 'Ensure complex filter arrays are properly stringified and encoded when fetching work packages.' + }, + { + subject: 'Add saved query execution tools', + type: feature_type, + status: status_new, + priority: priority_normal, + description: 'Allow LLMs to execute preconfigured OpenProject queries and inspect results.' + }, + { + subject: 'Initial repository bootstrap and documentation', + type: task_type, + status: status_closed, + priority: priority_normal, + description: 'Set up AGENTS.md, ARCHITECTURE.md, and initial decision records.' + } +] + +created_wps = [] +sample_packages.each do |data| + wp = WorkPackage.find_or_initialize_by(project: test_project, subject: data[:subject]) + wp.author = admin + wp.assigned_to = admin + wp.type = data[:type] + wp.status = data[:status] + wp.priority = data[:priority] + wp.description = data[:description] + wp.save!(validate: false) + created_wps << { id: wp.id, subject: wp.subject, status: wp.status&.name, type: wp.type&.name } +end +puts "[Seeder] Seeded #{created_wps.size} work packages in '#{test_project.identifier}'." + +# 7. Create saved query +query = Query.find_or_initialize_by(name: 'MCP Active Tasks', project: test_project) +query.user = admin +query.public = true if query.respond_to?(:public=) +query.include_subprojects = true if query.respond_to?(:include_subprojects=) +query.timestamps = [] if query.respond_to?(:timestamps=) +query.column_names = [:id, :subject, :status, :assigned_to] if query.respond_to?(:column_names=) +query.save!(validate: false) +puts "[Seeder] Seeded saved query 'MCP Active Tasks' (ID: #{query.id})." + +# 8. Output summary payload for test automation +summary = { + status: 'ok', + baseUrl: 'http://localhost:8080', + apiKey: api_token, + adminUser: admin.login, + projectId: test_project.id, + projectIdentifier: test_project.identifier, + queryId: query.id, + workPackages: created_wps, + totalProjects: Project.count, + totalWorkPackages: WorkPackage.count +} + +puts "---SEED_RESULT_START---" +puts summary.to_json +puts "---SEED_RESULT_END---" +puts "[Seeder] Completed successfully." diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100755 index 0000000..acc1d4b --- /dev/null +++ b/scripts/seed.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# ============================================================================== +# OpenProject MCP Companion - Seed Script +# Waits for OpenProject to be healthy, runs test data seeder, and saves API key. +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +BASE_URL="${OPENPROJECT_BASE_URL:-http://localhost:8080}" +MAX_WAIT_SECONDS=180 +INTERVAL=5 + +echo "[seed.sh] Waiting for OpenProject to be healthy at ${BASE_URL}..." + +elapsed=0 +until curl -s -f "${BASE_URL}/health_checks/default" >/dev/null 2>&1; do + if [ "$elapsed" -ge "$MAX_WAIT_SECONDS" ]; then + echo "[seed.sh] ERROR: Timed out waiting for OpenProject after ${MAX_WAIT_SECONDS} seconds." + echo "[seed.sh] Check container logs with: docker compose logs web" + exit 1 + fi + echo "[seed.sh] OpenProject not ready yet. Retrying in ${INTERVAL}s (${elapsed}s / ${MAX_WAIT_SECONDS}s)..." + sleep "$INTERVAL" + elapsed=$((elapsed + INTERVAL)) +done + +echo "[seed.sh] OpenProject is up and healthy!" +echo "[seed.sh] Running seed script inside container..." + +SEED_OUTPUT=$(docker compose -f "${ROOT_DIR}/docker-compose.yml" exec -T web bundle exec rails runner /scripts/seed-test-data.rb) + +echo "${SEED_OUTPUT}" + +JSON_PAYLOAD=$(echo "${SEED_OUTPUT}" | sed -n '/---SEED_RESULT_START---/,/---SEED_RESULT_END---/p' | grep -v -- '---SEED_RESULT_') + +if [ -z "${JSON_PAYLOAD}" ]; then + echo "[seed.sh] ERROR: Could not extract seed payload from output." + exit 1 +fi + +API_KEY=$(python3 -c "import json, sys; print(json.loads(sys.stdin.read())['apiKey'])" <<< "${JSON_PAYLOAD}") +PROJECT_IDENTIFIER=$(python3 -c "import json, sys; print(json.loads(sys.stdin.read())['projectIdentifier'])" <<< "${JSON_PAYLOAD}") + +# Update .env.test +cat < "${ROOT_DIR}/.env.test" +# Auto-generated by scripts/seed.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") +OPENPROJECT_BASE_URL=${BASE_URL} +OPENPROJECT_API_KEY=${API_KEY} +OPENPROJECT_TEST_PROJECT=${PROJECT_IDENTIFIER} +EOF + +# Also update .env.local if not present or populate key +if [ ! -f "${ROOT_DIR}/.env.local" ]; then + cp "${ROOT_DIR}/.env.test" "${ROOT_DIR}/.env.local" +fi + +echo "" +echo "[seed.sh] Success! Test environment configured." +echo "[seed.sh] API credentials written to .env.test and .env.local" +echo "[seed.sh] Base URL: ${BASE_URL}" +echo "[seed.sh] API Key: ${API_KEY}" +echo "[seed.sh] Test Project: ${PROJECT_IDENTIFIER}" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..db589c6 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# ============================================================================== +# OpenProject MCP Companion - Start & Seed Stack +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${ROOT_DIR}" + +if [ ! -f .env ]; then + echo "[start.sh] Creating .env from .env.example..." + cp .env.example .env +fi + +echo "[start.sh] Starting OpenProject docker compose stack..." +docker compose up -d + +echo "[start.sh] Running seed script to prepare test data and API token..." +"${SCRIPT_DIR}/seed.sh" + +echo "[start.sh] OpenProject MCP test stack is ready!" diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..b34d6f8 --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# ============================================================================== +# OpenProject MCP Companion - Stop Stack +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo "[stop.sh] Stopping OpenProject docker compose stack..." +docker compose -f "${ROOT_DIR}/docker-compose.yml" down +echo "[stop.sh] Stack stopped."