diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..9c36fbd --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "devops-os": { + "command": ".venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": ".", + "env": { + "PYTHONPATH": "." + } + } + } +} diff --git a/.github/workflows/mcp-setup-smoke.yml b/.github/workflows/mcp-setup-smoke.yml new file mode 100644 index 0000000..89f4f64 --- /dev/null +++ b/.github/workflows/mcp-setup-smoke.yml @@ -0,0 +1,224 @@ +name: MCP Setup Smoke Test + +# What this workflow proves +# ───────────────────────── +# The setup_devops_os_mcp.sh script is the primary on-boarding path for users +# who want to connect DevOps-OS to Claude Code. This workflow proves the +# full installation flow works end-to-end on a clean Ubuntu machine: +# +# 1. setup_devops_os_mcp.sh --local creates a Python venv at .venv/ +# 2. All MCP server dependencies are installed (importable from the venv) +# 3. The MCP server process starts successfully under the venv interpreter +# 4. The server completes the MCP initialize handshake +# 5. tools/list returns all 8 expected DevOps-OS tools +# 6. The claude mcp add / claude mcp list commands are exercised via a +# stub so the registration code path is validated even without a real +# Claude CLI binary or API key + +on: + push: + branches: [main, "copilot/**"] + paths: + - "mcp_server/**" + - ".github/workflows/mcp-setup-smoke.yml" + pull_request: + branches: [main] + paths: + - "mcp_server/**" + - ".github/workflows/mcp-setup-smoke.yml" + +permissions: + contents: read + +jobs: + mcp-setup-smoke: + name: MCP Setup Smoke Test + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # ── Step 1: stub the 'claude' binary ──────────────────────────────────── + # The setup script calls `claude mcp list` and `claude mcp add`. + # We provide a minimal stub that: + # • Returns a non-zero exit code for `mcp list` (so the script skips + # the "remove existing entry" path) — mirrors a fresh install. + # • Exits 0 for `mcp add` and logs the invocation so we can assert + # the correct arguments were passed. + # This validates the registration code path without a real Claude binary. + - name: Install claude CLI stub + run: | + mkdir -p "$HOME/.local/bin" + cat > "$HOME/.local/bin/claude" << 'EOF' + #!/usr/bin/env bash + # Claude CLI stub for CI smoke testing. + # Logs every invocation to $HOME/claude-stub.log. + echo "claude stub called: $*" >> "$HOME/claude-stub.log" + if [[ "$1 $2" == "mcp list" ]]; then + # Return 1 so the 'grep -q "^devops-os"' check fails ─ simulates + # a clean install with no prior registration. + exit 1 + fi + # All other sub-commands (mcp add, mcp remove) succeed silently. + exit 0 + EOF + chmod +x "$HOME/.local/bin/claude" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # ── Step 2: run the setup script in local mode ────────────────────────── + - name: Run setup_devops_os_mcp.sh --local + run: bash mcp_server/setup_devops_os_mcp.sh --local + + # ── Step 3: verify .venv was created ──────────────────────────────────── + - name: Verify .venv was created + run: | + echo "Checking for .venv directory..." + test -d .venv || { echo "ERROR: .venv was not created by setup script"; exit 1; } + test -f .venv/bin/python || { echo "ERROR: .venv/bin/python not found"; exit 1; } + echo "✓ .venv exists: $(.venv/bin/python --version)" + + # ── Step 4: verify MCP server dependencies are installed ──────────────── + - name: Verify MCP server dependencies installed in venv + run: | + echo "Checking that 'mcp' package is importable from .venv..." + .venv/bin/python -c " +import importlib.metadata +v = importlib.metadata.version('mcp') +from mcp.server.fastmcp import FastMCP +print('✓ mcp version:', v, '— FastMCP importable') +" || { echo "ERROR: mcp package not installed in .venv"; exit 1; } + + echo "Checking that 'yaml' package is importable from .venv..." + .venv/bin/python -c "import yaml; print('✓ pyyaml imported')" \ + || { echo "ERROR: pyyaml not installed in .venv"; exit 1; } + + # ── Step 5: verify claude stub was called with the right arguments ─────── + - name: Verify claude mcp add was invoked with correct arguments + run: | + echo "Contents of claude-stub.log:" + cat "$HOME/claude-stub.log" + + # Assert 'claude mcp add' was called + grep -q "mcp add" "$HOME/claude-stub.log" \ + || { echo "ERROR: 'claude mcp add' was never called by the setup script"; exit 1; } + + # Assert --transport stdio was passed + grep -q "stdio" "$HOME/claude-stub.log" \ + || { echo "ERROR: '--transport stdio' was not passed to 'claude mcp add'"; exit 1; } + + # Assert the server name 'devops-os' was registered + grep -q "devops-os" "$HOME/claude-stub.log" \ + || { echo "ERROR: 'devops-os' server name was not passed to 'claude mcp add'"; exit 1; } + + # Assert mcp_server.server module was referenced + grep -q "mcp_server.server" "$HOME/claude-stub.log" \ + || { echo "ERROR: 'mcp_server.server' module not referenced in 'claude mcp add' call"; exit 1; } + + echo "✓ claude mcp add was called with all expected arguments" + + # ── Step 6: verify the MCP server starts and responds via JSON-RPC ─────── + - name: Verify MCP server starts and responds to tools/list + run: | + .venv/bin/python - << 'PYEOF' + import json, os, subprocess, sys + + repo_root = os.getcwd() + env = {**os.environ, "PYTHONPATH": repo_root} + venv_python = os.path.join(repo_root, ".venv", "bin", "python") + + print("Starting MCP server via venv python:", venv_python) + proc = subprocess.Popen( + [venv_python, "-m", "mcp_server.server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + cwd=repo_root, + ) + + def send(method, params=None, req_id=None): + msg = {"jsonrpc": "2.0", "method": method} + if req_id is not None: + msg["id"] = req_id + if params is not None: + msg["params"] = params + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + if req_id is not None: + return json.loads(proc.stdout.readline()) + + # MCP initialize handshake + init_resp = send("initialize", { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "1.0"}, + }, req_id=1) + + if "error" in init_resp: + proc.terminate() + print("ERROR: initialize failed:", init_resp["error"]) + sys.exit(1) + + proto_ver = init_resp["result"]["protocolVersion"] + server_name = init_resp["result"]["serverInfo"]["name"] + print(f"✓ Initialize handshake OK — server: {server_name!r}, protocol: {proto_ver}") + + # Send notifications/initialized (required before tool calls) + send("notifications/initialized") + + # tools/list + list_resp = send("tools/list", {}, req_id=2) + if "error" in list_resp: + proc.terminate() + print("ERROR: tools/list failed:", list_resp["error"]) + sys.exit(1) + + tools = list_resp["result"]["tools"] + names = {t["name"] for t in tools} + expected = { + "generate_github_actions_workflow", + "generate_gitlab_ci_pipeline", + "generate_jenkins_pipeline", + "generate_k8s_config", + "generate_argocd_config", + "generate_sre_configs", + "scaffold_devcontainer", + "generate_unittest_config", + } + missing = expected - names + if missing: + proc.terminate() + print("ERROR: missing tools:", missing) + sys.exit(1) + + print(f"✓ tools/list returned {len(tools)} tools, all 8 DevOps-OS tools present") + for t in sorted(tools, key=lambda x: x["name"]): + print(f" • {t['name']}") + + # Quick tools/call sanity check: generate a GHA workflow + call_resp = send("tools/call", { + "name": "generate_github_actions_workflow", + "arguments": {"name": "smoke-test-app", "languages": "python"}, + }, req_id=3) + + if "error" in call_resp: + proc.terminate() + print("ERROR: tools/call failed:", call_resp["error"]) + sys.exit(1) + + content_text = call_resp["result"]["content"][0]["text"] + assert "smoke-test-app" in content_text, "app name not in GHA output" + assert "runs-on:" in content_text, "not a valid GHA YAML" + print("✓ tools/call generate_github_actions_workflow returned valid GHA YAML") + + proc.terminate() + proc.wait(timeout=5) + print("\nAll smoke checks passed ✓") + PYEOF diff --git a/.github/workflows/sanity.yml b/.github/workflows/sanity.yml index 12e2915..d13b0f8 100644 --- a/.github/workflows/sanity.yml +++ b/.github/workflows/sanity.yml @@ -70,6 +70,11 @@ jobs: - name: "Scenario: MCP server — Dev container tool" run: pytest tests/test_comprehensive.py::TestMCPServerDevcontainer -v + # ── MCP wire-protocol tests ─────────────────────────────────────────── + + - name: "Scenario: MCP wire protocol — handshake, tool discovery, invocation" + run: pytest tests/test_mcp_protocol.py -v + # ── AI skills definitions ────────────────────────────────────────────── - name: "Scenario: AI skills definitions (OpenAI & Claude)" @@ -88,7 +93,7 @@ jobs: - name: Generate combined HTML report if: always() run: | - pytest cli/test_cli.py mcp_server/test_server.py tests/test_comprehensive.py \ + pytest cli/test_cli.py mcp_server/test_server.py tests/test_comprehensive.py tests/test_mcp_protocol.py \ --html=sanity-report.html --self-contained-html -q - name: Upload sanity test report diff --git a/.gitignore b/.gitignore index 4c5e7f3..1e1165b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,10 +62,15 @@ pytestdebug.log *.iws .idea_modules/ -# VS Code -.vscode/ +# VS Code (ignore personal settings, track shared MCP config) +.vscode/* +!.vscode/mcp.json *.code-workspace +# Cursor (ignore personal settings, track shared MCP config) +.cursor/* +!.cursor/mcp.json + # Spyder .spyderproject .spyproject diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..9c36fbd --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "devops-os": { + "command": ".venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": ".", + "env": { + "PYTHONPATH": "." + } + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..df3bbef --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + "devops-os": { + "type": "stdio", + "command": ".venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": ".", + "env": { + "PYTHONPATH": "." + } + } + } +} diff --git a/README.md b/README.md index e770aec..ccf7e71 100644 --- a/README.md +++ b/README.md @@ -209,17 +209,35 @@ Single Cli Command for Platform Engineering Capabilities - Dev Container and CIC --- -### 6 — Use with AI (MCP Server) - **WIP** +### 6 — Use with AI (MCP Server) + +Connect DevOps-OS tools to any MCP-compatible AI assistant: **Claude Code, Claude Desktop, Cursor, VS Code Copilot, Windsurf, and Zed**. + +**Fastest setup (Claude Code CLI):** ```bash -pip install -r mcp_server/requirements.txt -python mcp_server/server.py +# Download the setup script +curl -fsSLo setup_devops_os_mcp.sh https://raw.githubusercontent.com/cloudengine-labs/devops_os/main/mcp_server/setup_devops_os_mcp.sh + +# (Optional but recommended) Inspect the script before running it +less setup_devops_os_mcp.sh + +# Run the setup script +bash setup_devops_os_mcp.sh ``` -Add to your `claude_desktop_config.json` and ask Claude: -> *"Generate a complete CI/CD GitHub Actions workflow for my Python API with Kubernetes deployment using ArgoCD."* +Already cloned the repo? Run locally instead: + +```bash +bash mcp_server/setup_devops_os_mcp.sh --local +``` + +Then ask your AI assistant: +> *"Generate a complete GitHub Actions CI/CD workflow for my Python API with Kubernetes deployment using ArgoCD."* + +**[Full setup guide →](mcp_server/README.md)** — covers Claude Desktop, Cursor, VS Code, Windsurf, Zed, and troubleshooting. -See **[mcp_server/README.md](mcp_server/README.md)** for full setup and **[skills/README.md](skills/README.md)** for Claude API & OpenAI function-calling examples. +See **[skills/README.md](skills/README.md)** for Claude API & OpenAI function-calling examples. --- diff --git a/mcp_server/README.md b/mcp_server/README.md index 46b6933..e756678 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -1,88 +1,331 @@ # DevOps-OS MCP Server -The DevOps-OS MCP (Model Context Protocol) server exposes the DevOps-OS pipeline -automation tools to any MCP-compatible AI assistant — including **Claude** (via -Claude Desktop / Claude API) and **ChatGPT** (via custom GPT Actions). +The DevOps-OS **Model Context Protocol (MCP) server** turns any MCP-compatible AI assistant into a DevOps automation engine. Ask it in plain English and it generates production-ready CI/CD pipelines, Kubernetes manifests, SRE dashboards, and more — no YAML knowledge required. ## Available Tools -| Tool | Description | -|------|-------------| -| `generate_github_actions_workflow` | Generate a GitHub Actions CI/CD workflow YAML | -| `generate_jenkins_pipeline` | Generate a Jenkins Declarative Pipeline (Jenkinsfile) | -| `generate_k8s_config` | Generate Kubernetes Deployment + Service manifests | -| `scaffold_devcontainer` | Generate `devcontainer.json` and `devcontainer.env.json` | -| `generate_gitlab_ci_pipeline` | Generate a GitLab CI/CD pipeline configuration (`.gitlab-ci.yml`) | -| `generate_argocd_config` | Generate Argo CD Application and related Kubernetes manifests | -| `generate_sre_configs` | Generate SRE-related configuration (e.g., monitoring and alerting setup) | +| Tool | What it generates | +|------|-------------------| +| `generate_github_actions_workflow` | GitHub Actions CI/CD workflow YAML | +| `generate_jenkins_pipeline` | Jenkins Declarative Pipeline (Jenkinsfile) | +| `generate_gitlab_ci_pipeline` | GitLab CI/CD pipeline (`.gitlab-ci.yml`) | +| `generate_k8s_config` | Kubernetes Deployment + Service manifests | +| `generate_argocd_config` | Argo CD Application / AppProject or Flux CRs | +| `generate_sre_configs` | Prometheus alert rules, Grafana dashboards, SLO manifests, Alertmanager routing/config YAML | +| `scaffold_devcontainer` | `devcontainer.json` + `devcontainer.env.json` | +| `generate_unittest_config` | Unit test configs for pytest, Jest, Vitest, Mocha, Go | -## Installation +--- + +## Quick Start — Which Method Is Right for You? + +| Your situation | Recommended method | +|---------------|--------------------| +| Use **Claude Code** (CLI) | [Automated setup script](#-option-a-automated-setup-claude-code-cli) | +| Use **Claude Desktop** (GUI app) | [Claude Desktop config](#-option-b-claude-desktop-app) | +| Use **Cursor** IDE | [Cursor MCP config](#-option-c-cursor-ide) | +| Use **VS Code** with GitHub Copilot | [VS Code MCP config](#-option-d-vs-code--github-copilot) | +| Use **Windsurf** | [Windsurf config](#-option-e-windsurf) | +| Use **Zed** editor | [Zed config](#-option-f-zed-editor) | +| Already cloned the repo | Add `--local` to any script below | + +--- + +## Step 0 — Prerequisites (all methods) + +| Requirement | Minimum version | Check | +|-------------|----------------|-------| +| Python | 3.10+ | `python3 --version` | +| pip | any recent | `pip --version` | +| git | any recent | `git --version` | + +You do **not** need Docker, Kubernetes, or any cloud accounts. + +--- + +## Option A — Automated Setup (Claude Code CLI) + +The fastest path. One command clones the repo, installs dependencies, and registers the server. ```bash -pip install -r mcp_server/requirements.txt +# Download the setup script +curl -fsSLo setup_devops_os_mcp.sh https://raw.githubusercontent.com/cloudengine-labs/devops_os/main/mcp_server/setup_devops_os_mcp.sh + +# (Optional but recommended) Inspect the script before running it +less setup_devops_os_mcp.sh + +# Run the setup script +bash setup_devops_os_mcp.sh ``` -## Running the Server +**Already cloned the repo?** Run the script in local mode instead of downloading it: ```bash -# Run as a stdio MCP server (default — for Claude Desktop and most MCP clients) -python -m mcp_server.server +# From inside your devops_os clone: +bash mcp_server/setup_devops_os_mcp.sh --local +``` + +**Custom install directory:** -# Or directly -python mcp_server/server.py +```bash +INSTALL_DIR=~/projects/devops_os bash mcp_server/setup_devops_os_mcp.sh ``` -## Connecting to Claude Desktop +**Verify it worked:** -Add the following to your `claude_desktop_config.json` -(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): +```bash +claude mcp list # should show "devops-os" +claude mcp get devops-os # shows the full config +``` -```json -{ - "mcpServers": { - "devops-os": { - "command": "python", - "args": ["-m", "mcp_server.server"], - "cwd": "/path/to/devops_os" - } - } -} +**Test it:** + +``` +claude +> Generate a GitHub Actions workflow for a Python + Node.js app with Docker build and Kubernetes deployment ``` -Restart Claude Desktop and ask it to: +--- + +## Option B — Claude Desktop App + +1. **Clone and install:** + + ```bash + git clone https://github.com/cloudengine-labs/devops_os.git ~/devops_os + cd ~/devops_os + python3 -m venv .venv + source .venv/bin/activate # macOS/Linux + # .venv\Scripts\activate # Windows + pip install -r mcp_server/requirements.txt + ``` + +2. **Find your config file:** + + | OS | Path | + |----|------| + | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | + | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | + | Linux | `~/.config/Claude/claude_desktop_config.json` | + +3. **Add this block** (replace `/YOUR/HOME` with the actual path from step 1): + + ```json + { + "mcpServers": { + "devops-os": { + "command": "/YOUR/HOME/devops_os/.venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": "/YOUR/HOME/devops_os" + } + } + } + ``` + + > **Tip:** Use the full venv path, not just `python`, so the correct dependencies are used. + +4. **Restart Claude Desktop**, then ask: + > *"Generate a Jenkins pipeline for a Java Spring Boot app that builds a Docker image and deploys to Kubernetes."* + +--- + +## Option C — Cursor IDE + +1. **Clone and install** (same as step 1 above). + +2. **Create `.cursor/mcp.json`** in your project root (or copy from the devops_os repo): + + ```json + { + "mcpServers": { + "devops-os": { + "command": "/YOUR/HOME/devops_os/.venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": "/YOUR/HOME/devops_os" + } + } + } + ``` + +3. **Reload Cursor** (Cmd+Shift+P → "Developer: Reload Window"). + +4. Open the Cursor chat panel and ask: + > *"Scaffold a devcontainer for a Go + Python project with Terraform and kubectl."* + +> **Project-level config:** The `.cursor/mcp.json` in this repo is pre-configured and works if you clone devops_os and open it directly in Cursor. + +--- + +## Option D — VS Code + GitHub Copilot + +VS Code 1.99+ supports MCP servers in GitHub Copilot Agent mode. + +1. **Clone and install** (same as step 1 in Option B). + +2. **Create `.vscode/mcp.json`** in your workspace: + + ```json + { + "servers": { + "devops-os": { + "type": "stdio", + "command": "/YOUR/HOME/devops_os/.venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": "/YOUR/HOME/devops_os" + } + } + } + ``` + +3. Open **GitHub Copilot Chat** in Agent mode and ask: + > *"Use devops-os to generate Kubernetes manifests for my app."* -> "Generate a complete GitHub Actions CI/CD workflow for a Python + Node.js project -> with Kubernetes deployment using Kustomize." +> **Note:** VS Code MCP support requires GitHub Copilot and VS Code 1.99 or later. + +--- + +## Option E — Windsurf + +1. **Clone and install** (same as step 1 in Option B). + +2. Open Windsurf settings: **Windsurf → Preferences → Cascade → MCP Servers**. + +3. Add this configuration: + + ```json + { + "mcpServers": { + "devops-os": { + "command": "/YOUR/HOME/devops_os/.venv/bin/python", + "args": ["-m", "mcp_server.server"], + "cwd": "/YOUR/HOME/devops_os" + } + } + } + ``` + + Alternatively, edit `~/.codeium/windsurf/mcp_config.json` directly. + +4. Restart Windsurf and ask Cascade: + > *"Generate SRE configs for my payment-service with a 99.9% SLO target."* + +--- + +## Option F — Zed Editor + +1. **Clone and install** (same as step 1 in Option B). + +2. Open `~/.config/zed/settings.json` and add: + + ```json + { + "context_servers": { + "devops-os": { + "command": { + "path": "/YOUR/HOME/devops_os/.venv/bin/python", + "args": ["-m", "mcp_server.server"], + "env": { + "PYTHONPATH": "/YOUR/HOME/devops_os" + } + } + } + } + } + ``` + +3. Restart Zed and use the Assistant panel to ask: + > *"Create a GitLab CI pipeline for a Python microservice."* + +--- ## Example Prompts -``` -Generate a GitHub Actions workflow for a Java Spring Boot app with kubectl deployment. +Once connected, try these prompts in any AI tool: -Create a Jenkins pipeline for a Python microservice with Docker build and push stages. +``` +Generate a complete GitHub Actions CI/CD workflow for a Python + Node.js project +with Docker build and Kubernetes deployment using Kustomize. -Scaffold a devcontainer for a Go + Python project with Terraform and kubectl. +Create a Jenkins pipeline for a Java Spring Boot service with Maven build, +Docker image push to ECR, and ArgoCD deployment. -Generate Kubernetes manifests for an app called 'api-service' using image +Generate Kubernetes manifests for 'api-service' using image 'ghcr.io/myorg/api-service:v1.2.3' with 3 replicas on port 8080. -``` -## Using with OpenAI / Custom GPT +Scaffold a devcontainer for a Go + Python project with Terraform, kubectl, and k9s. + +Generate SRE configs (Prometheus alerts, Grafana dashboard, SLO manifest) +for 'payment-service' owned by team 'platform' with 99.9% availability target. + +Create unit test configuration for a Python project named 'data-pipeline'. + +Generate a GitLab CI pipeline with Docker build and Kubernetes deployment stages +for a Python microservice. +``` -See [`../skills/README.md`](../skills/README.md) for instructions on adding the -DevOps-OS tools to a Custom GPT via OpenAI function calling or GPT Actions. +--- ## Architecture ``` -AI Assistant (Claude / ChatGPT) - │ MCP / function-call request +AI Assistant (Claude / Cursor / VS Code / Windsurf / Zed) + │ MCP stdio request ▼ -DevOps-OS MCP Server (mcp_server/server.py) - │ calls Python functions +DevOps-OS MCP Server (mcp_server/server.py) + │ calls Python scaffold functions ▼ -DevOps-OS CLI scaffold modules - ├─ cli/scaffold_gha.py → GitHub Actions YAML - ├─ cli/scaffold_jenkins.py → Jenkinsfile +DevOps-OS CLI modules + ├─ cli/scaffold_gha.py → GitHub Actions YAML + ├─ cli/scaffold_jenkins.py → Jenkinsfile + ├─ cli/scaffold_gitlab.py → .gitlab-ci.yml + ├─ cli/scaffold_argocd.py → ArgoCD / Flux manifests + ├─ cli/scaffold_sre.py → Prometheus / Grafana / SLO + ├─ cli/scaffold_devcontainer.py→ devcontainer.json + ├─ cli/scaffold_unittest.py → pytest / Jest / Go test └─ kubernetes/k8s-config-generator.py → K8s manifests ``` + +--- + +## Troubleshooting + +### "Module not found" or "No module named mcp" +The venv is missing or incomplete. Re-run: +```bash +/path/to/devops_os/.venv/bin/pip install -r /path/to/devops_os/mcp_server/requirements.txt +``` + +### "command not found: claude" (during setup) +Install Claude Code first: [claude.ai/code](https://claude.ai/code) + +### Server shows in `claude mcp list` but tools don't appear +Check that `PYTHONPATH` points to the repo root: +```bash +claude mcp get devops-os # inspect the registered command +``` +Re-register manually if needed: +```bash +claude mcp remove devops-os +claude mcp add --transport stdio devops-os --scope user \ + -- /path/to/devops_os/.venv/bin/python -m mcp_server.server \ + --env PYTHONPATH=/path/to/devops_os +``` + +### Tools appear but return errors +Run the test suite to verify the installation: +```bash +cd /path/to/devops_os +.venv/bin/python -m pytest mcp_server/test_server.py -v +``` + +### Windows path format +On Windows, use forward slashes or escaped backslashes in JSON config: +```json +"command": "C:/Users/you/devops_os/.venv/Scripts/python.exe" +``` + +--- + +## Using with Claude API / OpenAI function calling + +See [`../skills/README.md`](../skills/README.md) for examples of calling the DevOps-OS tools directly from the Anthropic or OpenAI Python SDKs (no MCP client required). diff --git a/mcp_server/setup_devops_os_mcp.sh b/mcp_server/setup_devops_os_mcp.sh new file mode 100755 index 0000000..82a79ba --- /dev/null +++ b/mcp_server/setup_devops_os_mcp.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# setup_devops_os_mcp.sh +# One-command setup: clones devops_os, creates a Python venv, installs deps, +# and registers the MCP server with Claude Code. +# +# Usage: +# # Install from GitHub (default) — clones into ~/devops_os +# bash setup_devops_os_mcp.sh +# +# # Use a custom install directory +# INSTALL_DIR=~/my/path bash setup_devops_os_mcp.sh +# +# # Already inside a clone? Register without re-cloning +# bash mcp_server/setup_devops_os_mcp.sh --local +# +# # Combine: specify directory and skip clone +# INSTALL_DIR=/opt/devops_os bash setup_devops_os_mcp.sh --local + +set -e + +REPO_URL="https://github.com/cloudengine-labs/devops_os.git" +LOCAL_MODE=false + +# --- Parse flags --- +for arg in "$@"; do + case "$arg" in + --local) LOCAL_MODE=true ;; + --help|-h) + cat <<'EOF' +Usage: + # Install from GitHub (default) — clones into ~/devops_os + bash setup_devops_os_mcp.sh + + # Use a custom install directory + INSTALL_DIR=~/my/path bash setup_devops_os_mcp.sh + + # Already inside a clone? Register without re-cloning + bash mcp_server/setup_devops_os_mcp.sh --local + + # Combine: specify directory and skip clone + INSTALL_DIR=/opt/devops_os bash setup_devops_os_mcp.sh --local + +Options: + --local Use the existing devops_os clone that contains this script. + -h, --help Show this help and exit. + +Environment variables: + INSTALL_DIR Target directory for cloning devops_os (default: $HOME/devops_os) + PYTHON Python interpreter to use (default: python3 or python) +EOF + exit 0 + ;; + esac +done + +# --- Resolve install directory --- +if [ "$LOCAL_MODE" = true ]; then + # Use the repo that contains this script + INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)" + echo "==> Local mode: using existing clone at $INSTALL_DIR" +else + INSTALL_DIR="${INSTALL_DIR:-$HOME/devops_os}" +fi + +# --- Check Python (require 3.10+) --- +if [ -n "${PYTHON:-}" ]; then + CANDIDATES=("$PYTHON") +else + CANDIDATES=() + if command -v python3 >/dev/null 2>&1; then + CANDIDATES+=("$(command -v python3)") + fi + if command -v python >/dev/null 2>&1; then + CANDIDATES+=("$(command -v python)") + fi +fi + +PYTHON="" +PYTHON_VERSION="" +for cmd in "${CANDIDATES[@]}"; do + ver="$("$cmd" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)" + if [ -z "$ver" ]; then + continue + fi + major="${ver%%.*}" + minor="${ver#*.}" + if [ "$major" -gt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -ge 10 ]; }; then + PYTHON="$cmd" + PYTHON_VERSION="$ver" + break + fi +done + +if [ -z "$PYTHON" ]; then + echo "Error: Python 3.10+ is required but was not found in PATH." >&2 + echo "Install it from https://www.python.org/downloads/ and re-run this script." >&2 + exit 1 +fi +echo "==> Using Python $PYTHON_VERSION at $PYTHON" + +# --- Clone or update repo (skipped in local mode) --- +if [ "$LOCAL_MODE" = false ]; then + if [ -d "$INSTALL_DIR/.git" ]; then + echo "==> Repo already exists at $INSTALL_DIR — pulling latest..." + git -C "$INSTALL_DIR" pull + else + echo "==> Cloning devops_os into $INSTALL_DIR..." + mkdir -p "$(dirname "$INSTALL_DIR")" + git clone "$REPO_URL" "$INSTALL_DIR" + fi +fi + +# --- Create venv if not present --- +VENV_DIR="$INSTALL_DIR/.venv" +if [ ! -d "$VENV_DIR" ]; then + echo "==> Creating Python virtual environment..." + "$PYTHON" -m venv "$VENV_DIR" +fi + +# --- Install dependencies --- +echo "==> Installing MCP server dependencies..." +"$VENV_DIR/bin/pip" install --quiet --upgrade pip +"$VENV_DIR/bin/pip" install --quiet -r "$INSTALL_DIR/mcp_server/requirements.txt" + +VENV_PYTHON="$VENV_DIR/bin/python" + +# --- Register with Claude Code --- +if claude mcp list 2>/dev/null | grep -q "^devops-os"; then + echo "==> Removing existing devops-os entry to avoid duplicates..." + claude mcp remove devops-os +fi + +echo "==> Registering devops-os MCP server with Claude Code..." +claude mcp add --transport stdio devops-os --scope user \ + -- "$VENV_PYTHON" -m mcp_server.server \ + --env PYTHONPATH="$INSTALL_DIR" + +echo "" +echo "✓ devops-os MCP server registered!" +echo "" +echo " Verify : claude mcp list" +echo " Test : claude" +echo " > Generate a GitHub Actions workflow for a Python app" +echo "" +echo " To add to other tools see: $INSTALL_DIR/mcp_server/README.md" diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py new file mode 100644 index 0000000..a6f0c1d --- /dev/null +++ b/tests/test_mcp_protocol.py @@ -0,0 +1,472 @@ +""" +MCP Wire-Protocol Tests for DevOps-OS MCP Server. + +What these tests prove +---------------------- +These tests exercise the *actual MCP JSON-RPC 2.0 wire protocol* by spawning +``python -m mcp_server.server`` as a real subprocess and communicating with it +over stdin/stdout — exactly the same channel that Claude Code, Cursor, VS Code +Copilot, or any other MCP client uses. + +They prove: + 1. **Handshake** — the server completes the MCP initialize / notifications/initialized + exchange and returns the correct ``protocolVersion`` and ``serverInfo``. + 2. **Tool discovery** — ``tools/list`` returns all eight expected DevOps-OS tools + with their names and descriptions intact. + 3. **Tool invocation** — ``tools/call`` for representative tools (GHA, Jenkins, + Kubernetes, SRE, ArgoCD, GitLab, devcontainer, unittest) returns a non-empty + text response that contains expected artifact content (YAML/JSON/Groovy + keywords). + 4. **Error handling** — calling a tool that does not exist returns a JSON-RPC + error response (not a crash or hang). + 5. **Sequential calls** — the server handles multiple back-to-back ``tools/call`` + requests without restarting, proving stateless-but-persistent request handling. + +No Claude CLI, no API key, and no network access are required. The server +communicates over stdio (the MCP stdio transport), so every test runs in any +CI environment that has Python 3.10+. +""" + +import json +import os +import subprocess +import sys +from typing import Any, Dict, Optional +# --------------------------------------------------------------------------- +# Expected tool names (all 8 DevOps-OS tools exposed by the MCP server) +# --------------------------------------------------------------------------- + +EXPECTED_TOOLS = { + "generate_github_actions_workflow", + "generate_gitlab_ci_pipeline", + "generate_jenkins_pipeline", + "generate_k8s_config", + "generate_argocd_config", + "generate_sre_configs", + "scaffold_devcontainer", + "generate_unittest_config", +} + +# Root of the repository (one level above this tests/ directory) +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# --------------------------------------------------------------------------- +# Helper: thin MCP session over a subprocess +# --------------------------------------------------------------------------- + +class _MCPSession: + """ + Manages a single MCP stdio session with ``mcp_server.server``. + + Usage:: + + with _MCPSession() as session: + tools = session.tools_list() + result = session.tools_call("generate_k8s_config", {"app_name": "demo"}) + """ + + MCP_PROTOCOL_VERSION = "2024-11-05" + + def __init__(self) -> None: + env = {**os.environ, "PYTHONPATH": _REPO_ROOT} + self._proc = subprocess.Popen( + [sys.executable, "-m", "mcp_server.server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + cwd=_REPO_ROOT, + ) + self._next_id = 1 + + # ------------------------------------------------------------------ + # Low-level transport helpers + # ------------------------------------------------------------------ + + def _send_request(self, method: str, params: Any = None) -> Dict: + """Send a JSON-RPC request and return the parsed response line.""" + req_id = self._next_id + self._next_id += 1 + msg: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method} + if params is not None: + msg["params"] = params + self._proc.stdin.write(json.dumps(msg) + "\n") + self._proc.stdin.flush() + raw = self._proc.stdout.readline() + if not raw: + stderr_out = self._proc.stderr.read() + raise RuntimeError( + f"MCP server produced no response for method '{method}'. " + f"Server stderr: {stderr_out!r}" + ) + return json.loads(raw) + + def _send_notification(self, method: str, params: Any = None) -> None: + """Send a JSON-RPC notification (no id, no response expected).""" + msg: Dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + self._proc.stdin.write(json.dumps(msg) + "\n") + self._proc.stdin.flush() + + # ------------------------------------------------------------------ + # MCP handshake + # ------------------------------------------------------------------ + + def initialize(self) -> Dict: + """Perform the MCP initialize / notifications/initialized handshake.""" + resp = self._send_request( + "initialize", + params={ + "protocolVersion": self.MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "pytest-mcp-test", "version": "1.0.0"}, + }, + ) + # Must send notifications/initialized before any tool calls + self._send_notification("notifications/initialized") + self._handshake_done = True + return resp + + # ------------------------------------------------------------------ + # High-level MCP methods + # ------------------------------------------------------------------ + + def tools_list(self) -> Dict: + """Call tools/list and return the full response.""" + return self._send_request("tools/list", params={}) + + def tools_call(self, tool_name: str, arguments: Optional[Dict] = None) -> Dict: + """Call tools/call for a given tool and return the full response.""" + return self._send_request( + "tools/call", + params={"name": tool_name, "arguments": arguments or {}}, + ) + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> "_MCPSession": + self.initialize() + return self + + def __exit__(self, *_) -> None: + try: + self._proc.stdin.close() + except Exception: + pass + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestMCPProtocolHandshake: + """Prove the MCP initialize / notifications/initialized exchange works correctly.""" + + def test_initialize_returns_protocol_version(self): + """ + Proves: the server completes the MCP handshake and echoes the agreed + protocol version (2024-11-05), which is the version Claude Code sends. + A client that sees a mismatched version refuses to connect. + """ + with _MCPSession() as session: + # initialize() was already called by __enter__; re-verify via a new session + pass # session just completing without error is the proof + + # Verify directly without context manager + s = _MCPSession() + resp = s.initialize() + try: + assert "result" in resp, f"Expected 'result' in initialize response, got: {resp}" + result = resp["result"] + assert result["protocolVersion"] == _MCPSession.MCP_PROTOCOL_VERSION, ( + f"Protocol version mismatch: {result.get('protocolVersion')}" + ) + assert "serverInfo" in result, "serverInfo missing from initialize response" + assert result["serverInfo"]["name"] == "devops-os", ( + f"Unexpected server name: {result['serverInfo'].get('name')}" + ) + finally: + s._proc.terminate() + s._proc.wait(timeout=5) + + def test_initialize_response_has_capabilities(self): + """ + Proves: the server advertises its capabilities block so MCP clients + know which features (tools, prompts, resources) are supported. + """ + s = _MCPSession() + resp = s.initialize() + try: + assert "result" in resp + assert "capabilities" in resp["result"], ( + "capabilities block missing from initialize response" + ) + caps = resp["result"]["capabilities"] + assert "tools" in caps, "tools capability not advertised" + finally: + s._proc.terminate() + s._proc.wait(timeout=5) + + +class TestMCPProtocolToolDiscovery: + """Prove tools/list returns the correct set of DevOps-OS tools.""" + + def test_tools_list_returns_all_expected_tools(self): + """ + Proves: every DevOps-OS scaffold tool is registered and visible via + the MCP tools/list call. A missing entry here means a Claude user + would not see (and could not invoke) that tool at all. + """ + with _MCPSession() as session: + resp = session.tools_list() + + assert "result" in resp, f"tools/list error: {resp}" + tools = resp["result"]["tools"] + names = {t["name"] for t in tools} + missing = EXPECTED_TOOLS - names + assert not missing, ( + f"These tools are missing from tools/list: {missing}\n" + f"Registered tools: {names}" + ) + + def test_every_tool_has_description(self): + """ + Proves: every tool carries a non-empty description string, which + Claude uses to decide when to invoke each tool. An empty description + would cause Claude to ignore or misuse the tool. + """ + with _MCPSession() as session: + resp = session.tools_list() + + tools = resp["result"]["tools"] + for tool in tools: + assert tool.get("description"), ( + f"Tool '{tool['name']}' has no description — Claude won't know when to use it" + ) + + def test_every_tool_has_input_schema(self): + """ + Proves: every tool exposes an inputSchema so MCP clients can validate + arguments before sending them and display parameter hints to users. + """ + with _MCPSession() as session: + resp = session.tools_list() + + tools = resp["result"]["tools"] + for tool in tools: + assert "inputSchema" in tool, ( + f"Tool '{tool['name']}' is missing inputSchema" + ) + + +class TestMCPProtocolToolInvocation: + """ + Prove that each tool can be invoked over the wire and returns valid artifact content. + + Each test sends a real tools/call JSON-RPC request and asserts that: + - The response has a ``result`` (not an ``error``) + - The result content contains expected artifact keywords + """ + + def _call_tool(self, tool_name: str, arguments: Optional[Dict] = None) -> str: + """Invoke a tool and return the text content of the first content item.""" + with _MCPSession() as session: + resp = session.tools_call(tool_name, arguments or {}) + assert "result" in resp, ( + f"tools/call '{tool_name}' returned error: {resp.get('error')}" + ) + content = resp["result"].get("content", []) + assert content, f"tools/call '{tool_name}' returned empty content" + text = content[0].get("text", "") + assert text, f"tools/call '{tool_name}' returned empty text" + return text + + # ---- GitHub Actions ------------------------------------------------ + + def test_call_generate_github_actions_workflow(self): + """ + Proves: the GHA tool generates a YAML workflow file over the MCP + wire protocol with the requested app name and language embedded. + """ + text = self._call_tool( + "generate_github_actions_workflow", + {"name": "mcp-test-app", "workflow_type": "build", "languages": "python"}, + ) + assert "mcp-test-app" in text, "app name not in GHA workflow output" + assert "runs-on:" in text, "not a valid GitHub Actions YAML (missing runs-on)" + + # ---- Jenkins ------------------------------------------------------- + + def test_call_generate_jenkins_pipeline(self): + """ + Proves: the Jenkins tool returns a valid Declarative Pipeline (Jenkinsfile) + with the pipeline { ... } block required by Jenkins. + """ + text = self._call_tool( + "generate_jenkins_pipeline", + {"name": "java-service", "pipeline_type": "build", "languages": "java"}, + ) + assert "pipeline" in text.lower(), "Jenkinsfile 'pipeline' block missing" + assert "agent" in text.lower(), "Jenkinsfile 'agent' directive missing" + + # ---- Kubernetes ---------------------------------------------------- + + def test_call_generate_k8s_config(self): + """ + Proves: the Kubernetes tool returns a Deployment manifest with the + requested app name and container image, confirming round-trip parameter + passing through the MCP wire protocol. + """ + text = self._call_tool( + "generate_k8s_config", + { + "app_name": "api-service", + "image": "ghcr.io/myorg/api-service:v1.0.0", + "replicas": 2, + "port": 8080, + }, + ) + assert "api-service" in text, "app name not in K8s manifest" + assert "Deployment" in text, "Deployment kind missing from K8s manifest" + assert "ghcr.io/myorg/api-service:v1.0.0" in text, "image not in K8s manifest" + + # ---- GitLab CI ----------------------------------------------------- + + def test_call_generate_gitlab_ci_pipeline(self): + """ + Proves: the GitLab CI tool returns a .gitlab-ci.yml with stage definitions. + """ + text = self._call_tool( + "generate_gitlab_ci_pipeline", + {"name": "py-service", "pipeline_type": "test", "languages": "python"}, + ) + assert "stages:" in text, ".gitlab-ci.yml stages block missing" + + # ---- ArgoCD -------------------------------------------------------- + + def test_call_generate_argocd_config(self): + """ + Proves: the ArgoCD tool returns a JSON bundle containing both an + Application and an AppProject manifest, proving JSON envelope wrapping + through the MCP protocol is intact. + """ + text = self._call_tool( + "generate_argocd_config", + {"app_name": "my-app", "repo_url": "https://github.com/org/repo"}, + ) + data = json.loads(text) + assert "argocd/application.yaml" in data, "application.yaml missing from ArgoCD bundle" + assert "argocd/appproject.yaml" in data, "appproject.yaml missing from ArgoCD bundle" + assert "Application" in data["argocd/application.yaml"] + + # ---- SRE configs --------------------------------------------------- + + def test_call_generate_sre_configs(self): + """ + Proves: the SRE tool returns a JSON bundle with Prometheus alert rules, + a Grafana dashboard JSON, and an SLO manifest — three separate artifact + types in one call. + """ + text = self._call_tool( + "generate_sre_configs", + {"name": "payment-service", "slo_type": "availability", "slo_target": 99.9}, + ) + data = json.loads(text) + assert "alert_rules_yaml" in data, "Prometheus alert rules missing from SRE bundle" + assert "grafana_dashboard_json" in data, "Grafana dashboard missing from SRE bundle" + assert "slo_yaml" in data, "SLO manifest missing from SRE bundle" + assert "PrometheusRule" in data["alert_rules_yaml"] + + # ---- Dev container ------------------------------------------------- + + def test_call_scaffold_devcontainer(self): + """ + Proves: the devcontainer tool returns a valid JSON bundle with both + devcontainer.json and devcontainer.env.json files, confirming the + JSON-in-JSON envelope survives the MCP wire protocol. + """ + text = self._call_tool( + "scaffold_devcontainer", + {"languages": "python,go", "cicd_tools": "docker,github_actions"}, + ) + data = json.loads(text) + assert "devcontainer_json" in data, "devcontainer.json missing from bundle" + assert "devcontainer_env_json" in data, "devcontainer.env.json missing from bundle" + + # ---- Unit test config ---------------------------------------------- + + def test_call_generate_unittest_config(self): + """ + Proves: the unittest scaffold tool returns pytest configuration for + a Python project over the MCP wire protocol. + """ + text = self._call_tool( + "generate_unittest_config", + {"name": "data-pipeline", "languages": "python"}, + ) + data = json.loads(text) + assert any("pytest" in k or "conftest" in k or "pyproject" in k for k in data), ( + f"No pytest config file found in unittest output keys: {list(data.keys())}" + ) + + +class TestMCPProtocolErrorHandling: + """Prove the server handles invalid requests gracefully without crashing.""" + + def test_call_unknown_tool_returns_error(self): + """ + Proves: calling a tool that does not exist returns a proper JSON-RPC + error response rather than crashing the server or hanging. After the + error, the server must still be alive for subsequent calls. + """ + with _MCPSession() as session: + resp = session.tools_call("this_tool_does_not_exist", {}) + # Should be an error response + assert "error" in resp or ( + "result" in resp and resp["result"].get("isError") + ), ( + f"Expected error for unknown tool, got: {resp}" + ) + # Server must still respond to subsequent requests + list_resp = session.tools_list() + assert "result" in list_resp, ( + "Server became unresponsive after an error response" + ) + + +class TestMCPProtocolSequentialCalls: + """Prove the server handles multiple back-to-back calls in one session.""" + + def test_multiple_sequential_tool_calls(self): + """ + Proves: a single MCP session can handle multiple back-to-back + ``tools/call`` requests without restarting. This mirrors real usage + where a user asks Claude several follow-up questions that each trigger + a DevOps-OS tool invocation. + """ + calls = [ + ("generate_github_actions_workflow", {"name": "seq-app", "languages": "python"}), + ("generate_jenkins_pipeline", {"name": "seq-svc", "languages": "java"}), + ("generate_k8s_config", {"app_name": "seq-k8s", "image": "nginx:latest"}), + ] + with _MCPSession() as session: + for tool_name, args in calls: + resp = session.tools_call(tool_name, args) + assert "result" in resp, ( + f"Sequential call to '{tool_name}' returned error: {resp.get('error')}" + ) + content = resp["result"].get("content", []) + assert content and content[0].get("text"), ( + f"Sequential call to '{tool_name}' returned empty content" + )