diff --git a/.github/workflows/auto-deploy.yml b/.github/workflows/auto-deploy.yml index cb1fe2e..d1f940b 100644 --- a/.github/workflows/auto-deploy.yml +++ b/.github/workflows/auto-deploy.yml @@ -1,130 +1,62 @@ -name: Auto Deploy on Push +name: Manual Production Deploy permissions: contents: read - packages: write - on: - push: - branches: [ main, master ] workflow_dispatch: - inputs: - environment: - description: 'Deployment environment' - required: true - default: 'production' - type: choice - options: - - production - - staging jobs: - build: - runs-on: ubuntu-latest - outputs: - image_tag: ${{ steps.meta.outputs.tags }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=ref,event=branch - type=sha,prefix= - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 - deploy: - needs: build + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - environment: ${{ github.event.inputs.environment || 'production' }} - + environment: production + concurrency: + group: mcp-server-production + cancel-in-progress: false steps: - - name: Deploy to server - if: ${{ env.SERVER_HOST != '' && env.SERVER_USER != '' && env.SERVER_SSH_KEY != '' }} - env: - SERVER_HOST: ${{ secrets.SERVER_HOST }} - SERVER_USER: ${{ secrets.SERVER_USER }} - SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ env.SERVER_HOST }} - username: ${{ env.SERVER_USER }} - key: ${{ env.SERVER_SSH_KEY }} - port: ${{ secrets.SERVER_PORT || 22 }} - script: | - set -e - - echo "🚀 Starting deployment..." - - # Navigate to project directory - cd /opt/mcp-server - - # Pull latest code - git pull origin main - - # Pull latest Docker image - docker compose pull - - # Restart services - docker compose up -d --remove-orphans - - # Wait for health check - echo "âŗ Waiting for health check..." - sleep 10 - - # Verify deployment - if curl -sf http://localhost:8000/health > /dev/null; then - echo "✅ Deployment successful!" - else - echo "❌ Health check failed!" - docker compose logs --tail=50 - exit 1 - fi - - # Clean up old images - docker image prune -f - - echo "🎉 Deployment complete!" + - name: Require deployment secrets + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} + run: | + test -n "$SERVER_HOST" + test -n "$SERVER_USER" + test -n "$SERVER_SSH_KEY" - notify: - needs: [build, deploy] - runs-on: ubuntu-latest - if: always() - - steps: - - name: Send notification - run: | - if [ "${{ needs.deploy.result }}" == "success" ]; then - echo "✅ Deployment successful!" - elif [ "${{ needs.deploy.result }}" == "skipped" ]; then - echo "â„šī¸ Deployment skipped (likely missing deploy secrets/environment)." - else - echo "âš ī¸ Deployment not successful: ${{ needs.deploy.result }}" - fi + - name: Deploy selected main commit + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} + EXPECTED_SHA: ${{ github.sha }} + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SERVER_HOST }} + username: ${{ env.SERVER_USER }} + key: ${{ env.SERVER_SSH_KEY }} + port: ${{ secrets.SERVER_PORT || 22 }} + envs: EXPECTED_SHA + script: | + set -euo pipefail + cd /opt/mcp-server + + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing deployment: production worktree is dirty" + git status --short + exit 1 + fi + + git fetch origin main + remote_sha="$(git rev-parse origin/main)" + if [ "$remote_sha" != "$EXPECTED_SHA" ]; then + echo "Refusing stale manual deployment: expected=$EXPECTED_SHA remote=$remote_sha" + exit 1 + fi + + git checkout main + git merge --ff-only origin/main + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + + bash scripts/deploy-production.sh "$EXPECTED_SHA" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0abfe80..8811bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,12 @@ name: CI/CD Pipeline permissions: contents: read - packages: read on: push: - branches: [ main, master ] + branches: [main] pull_request: - branches: [ main, master ] + branches: [main] workflow_dispatch: env: @@ -18,69 +17,103 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install pytest pytest-asyncio ruff mypy + - name: Install project and validation tools + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" - - name: Lint with ruff - run: ruff check src/ + - name: Lint correctness rules + run: ruff check src/ - - name: Compile sources - run: python -m compileall -q src + - name: Compile sources + run: python -m compileall -q src - - name: Import smoke - run: | - python -c "import mcp_server.main" - python -c "import mcp_server.api.routes" - python -c "import mcp_server.tools.mcp_tools" + - name: Import smoke + env: + MCP_SSH__HOST: ci.invalid + MCP_SSH__USER: ci-smoke + run: | + python -c "import mcp_server.main" + python -c "import mcp_server.api.routes" + python -c "import mcp_server.tools.mcp_tools" - - name: Run tests - run: pytest -q + - name: Run tests + run: pytest -q build: needs: verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: false - tags: mcp-server:${{ github.sha }} + - name: Build exact commit image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: mcp-server:${{ github.sha }} deploy: - needs: build + needs: [verify, build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + environment: production + concurrency: + group: mcp-server-production + cancel-in-progress: false steps: - - name: Deploy to server - if: ${{ env.SERVER_HOST != '' }} - env: - SERVER_HOST: ${{ secrets.SERVER_HOST }} - SERVER_USER: ${{ secrets.SERVER_USER }} - SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ env.SERVER_HOST }} - username: ${{ env.SERVER_USER }} - key: ${{ env.SERVER_SSH_KEY }} - script: | - cd /opt/mcp-server - git pull origin main - docker compose pull - docker compose up -d --build - curl -sf http://localhost:8000/health || exit 1 + - name: Require deployment secrets + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} + run: | + test -n "$SERVER_HOST" + test -n "$SERVER_USER" + test -n "$SERVER_SSH_KEY" + + - name: Deploy exact verified commit + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} + EXPECTED_SHA: ${{ github.sha }} + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SERVER_HOST }} + username: ${{ env.SERVER_USER }} + key: ${{ env.SERVER_SSH_KEY }} + port: ${{ secrets.SERVER_PORT || 22 }} + envs: EXPECTED_SHA + script: | + set -euo pipefail + cd /opt/mcp-server + + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing deployment: production worktree is dirty" + git status --short + exit 1 + fi + + git fetch origin main + remote_sha="$(git rev-parse origin/main)" + if [ "$remote_sha" != "$EXPECTED_SHA" ]; then + echo "Deployment superseded by newer main: expected=$EXPECTED_SHA remote=$remote_sha" + exit 0 + fi + + git checkout main + git merge --ff-only origin/main + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + + bash scripts/deploy-production.sh "$EXPECTED_SHA" diff --git a/.gitignore b/.gitignore index 64678ec..ffb02fd 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,8 @@ temp/ *.bak *.backup .runtime/ssh_targets.json + +# Repository hygiene +/test_write.txt +/tmp.txt +/*_backup.zip diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..97c973d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported version + +The `main` branch is the supported source line for this repository. + +## Reporting a vulnerability + +Do not open a public issue containing vulnerability details, credentials, private keys, access tokens, server addresses, or reproduction data that exposes infrastructure. + +Use GitHub's private vulnerability reporting / Security Advisory flow when it is available for this repository. If private reporting is unavailable, contact the repository owner privately through GitHub before publishing technical details. + +## Credential handling + +Real credentials and runtime secrets must never be committed. If a secret is committed or exposed in Actions logs, treat it as compromised: revoke or rotate it first, then remove the exposed material from the repository/history as a separate cleanup step. + +## Deployment invariant + +Production deployment must originate from an exact, verified `main` commit and must fail closed on dirty/diverged production worktrees or source-SHA mismatch. diff --git a/mcp_server_backup.zip b/mcp_server_backup.zip deleted file mode 100644 index 8226fa6..0000000 Binary files a/mcp_server_backup.zip and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index fcb3ad3..83c4584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,17 @@ target-version = ["py310", "py311", "py312"] line-length = 100 target-version = "py310" +[tool.ruff.lint] +# Keep CI semantics stable across Ruff releases. Correctness rules are blocking; +# broad modernization/style migrations are deliberate changes, not surprise CI drift. +select = ["E9", "F", "W605", "E722"] + +[tool.ruff.lint.per-file-ignores] +# Legacy compatibility modules are not part of the lint modernization campaign. +# Do not expand these exceptions without an explicit security/design review. +"src/mcp_server/tools/single_router_tool.py" = ["F401"] +"src/mcp_server/tools/unified_whitelist_tools.py" = ["F401", "F541", "W605"] + [tool.mypy] python_version = "3.10" strict = true diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..09c15d8 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +expected_sha="${1:?expected commit SHA is required}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +actual_sha="$(git rev-parse HEAD)" +if [ "$actual_sha" != "$expected_sha" ]; then + echo "Refusing deployment: source SHA mismatch expected=$expected_sha actual=$actual_sha" + exit 1 +fi + +docker compose up -d --build --remove-orphans + +healthy=0 +for _ in $(seq 1 30); do + if curl -fsS http://localhost:8000/health >/dev/null; then + healthy=1 + break + fi + sleep 2 +done + +if [ "$healthy" -ne 1 ]; then + echo "Deployment health check failed" + docker compose ps || true + docker compose logs --tail=100 mcp-server || true + exit 1 +fi + +echo "Deployment verified at $expected_sha" diff --git a/src/mcp_server/api/routes.py b/src/mcp_server/api/routes.py index 7d463db..1eb55e9 100644 --- a/src/mcp_server/api/routes.py +++ b/src/mcp_server/api/routes.py @@ -1,11 +1,9 @@ import asyncio import json -import logging import uuid from typing import Optional -from fastapi import APIRouter, Request, Response, HTTPException, Depends, Query +from fastapi import APIRouter, Request, HTTPException, Depends, Query from fastapi.responses import JSONResponse, RedirectResponse -from sse_starlette.sse import EventSourceResponse import structlog from mcp_server.core.settings import get_settings @@ -87,8 +85,8 @@ async def readiness_check(): try: await asyncio.wait_for(_ssh_client.connect(), timeout=5) checks["ssh"] = "ready" - except asyncio.TimeoutError as e: - checks["ssh"] = f"not_ready: timeout after 5s" + except asyncio.TimeoutError: + checks["ssh"] = "not_ready: timeout after 5s" ready = False except Exception as e: checks["ssh"] = f"not_ready: {str(e)}" diff --git a/src/mcp_server/api/sse_transport.py b/src/mcp_server/api/sse_transport.py index 768f4d1..2d24266 100644 --- a/src/mcp_server/api/sse_transport.py +++ b/src/mcp_server/api/sse_transport.py @@ -1,13 +1,10 @@ """SSE Transport for MCP Protocol - ChatGPT Compatible.""" import json -import logging import asyncio import uuid from typing import Optional, Dict, Any, AsyncGenerator -from datetime import datetime -from fastapi import Request, Query -from fastapi.responses import StreamingResponse +from fastapi import Request from sse_starlette.sse import EventSourceResponse import structlog diff --git a/src/mcp_server/auth/middleware.py b/src/mcp_server/auth/middleware.py index f05d3e7..b040313 100644 --- a/src/mcp_server/auth/middleware.py +++ b/src/mcp_server/auth/middleware.py @@ -1,9 +1,9 @@ """Authentication middleware for FastAPI.""" import logging -from typing import Optional, Callable, Any +from typing import Optional, Callable from functools import wraps -from mcp_server.auth.oauth import OAuthHandler, TokenInfo +from mcp_server.auth.oauth import OAuthHandler from fastapi import Request, HTTPException, Depends, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials diff --git a/src/mcp_server/auth/oauth.py b/src/mcp_server/auth/oauth.py index 4f027c7..4a3cbf4 100644 --- a/src/mcp_server/auth/oauth.py +++ b/src/mcp_server/auth/oauth.py @@ -2,14 +2,10 @@ import time import httpx import logging -from typing import Optional, Dict, Any, List -from dataclasses import dataclass, field -from datetime import datetime, timedelta -import json -import base64 +from typing import Optional, Dict, Any +from dataclasses import dataclass from jose import jwt, jwk, JWTError -from jose.utils import base64url_decode from mcp_server.core.settings import get_settings, OAuthSettings diff --git a/src/mcp_server/main.py b/src/mcp_server/main.py index cb5eca1..2ca5870 100644 --- a/src/mcp_server/main.py +++ b/src/mcp_server/main.py @@ -1,6 +1,5 @@ """Main FastAPI Application for MCP SSH Gateway.""" import logging -import asyncio from contextlib import asynccontextmanager from typing import Optional @@ -14,7 +13,7 @@ from mcp_server.core.settings import get_settings from mcp_server.auth.oauth import OAuthHandler -from mcp_server.auth.middleware import AuthMiddleware, set_oauth_handler +from mcp_server.auth.middleware import set_oauth_handler from mcp_server.tools.ssh_client import SSHClient from mcp_server.tools.mcp_tools import MCPTools from mcp_server.api.sse_transport import SSETransport, MCPProtocolHandler diff --git a/src/mcp_server/tools/direct_ops_tools.py b/src/mcp_server/tools/direct_ops_tools.py index 05d7dbb..41005a7 100644 --- a/src/mcp_server/tools/direct_ops_tools.py +++ b/src/mcp_server/tools/direct_ops_tools.py @@ -4,9 +4,8 @@ import os import shutil import subprocess -import tempfile from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from dataclasses import dataclass @@ -139,7 +138,7 @@ async def write_file(arguments: Dict[str, Any]) -> Dict[str, Any]: # Verify written = await asyncio.to_thread(p.read_text) if written != content: - return _result(f"Error: verification failed - content mismatch", True) + return _result("Error: verification failed - content mismatch", True) return _json_result({ "success": True, @@ -166,7 +165,7 @@ async def patch_file(arguments: Dict[str, Any]) -> Dict[str, Any]: matches = content.count(search) if matches == 0: - return _result(f"Error: search pattern not found (0 matches)", True) + return _result("Error: search pattern not found (0 matches)", True) new_content = content.replace(search, replace) @@ -178,7 +177,7 @@ async def patch_file(arguments: Dict[str, Any]) -> Dict[str, Any]: # Verify written = await asyncio.to_thread(p.read_text) if search in written: - return _result(f"Error: verification failed - search pattern still present", True) + return _result("Error: verification failed - search pattern still present", True) return _json_result({ "success": True, @@ -198,7 +197,7 @@ def _list_dir_sync(path_str: str) -> Dict[str, Any]: items = [] def _sort_key(x): try: return (not x.is_dir(), x.name.lower()) - except: return (False, x.name.lower()) + except OSError: return (False, x.name.lower()) for item in sorted(p.iterdir(), key=_sort_key): try: diff --git a/src/mcp_server/tools/executor.py b/src/mcp_server/tools/executor.py index 603199d..e8432ee 100644 --- a/src/mcp_server/tools/executor.py +++ b/src/mcp_server/tools/executor.py @@ -4,7 +4,7 @@ from typing import Optional, Dict, Any, List from dataclasses import dataclass -from mcp_server.tools.ssh_client import SSHClient, SSHResult +from mcp_server.tools.ssh_client import SSHClient from mcp_server.core.settings import get_settings logger = logging.getLogger(__name__) diff --git a/src/mcp_server/tools/extra_tools.py b/src/mcp_server/tools/extra_tools.py index 38d838d..8d2e210 100644 --- a/src/mcp_server/tools/extra_tools.py +++ b/src/mcp_server/tools/extra_tools.py @@ -1,7 +1,6 @@ """Extra narrow MCP tools for high-autonomy diagnostics, file inspection, and service bundles.""" import json import shlex -import time from dataclasses import dataclass from typing import Any, Callable, Dict, Optional diff --git a/src/mcp_server/tools/mcp_tools.py b/src/mcp_server/tools/mcp_tools.py index 574e1ba..86f0427 100644 --- a/src/mcp_server/tools/mcp_tools.py +++ b/src/mcp_server/tools/mcp_tools.py @@ -5,7 +5,6 @@ import logging from typing import Dict, Any, Callable, Optional, List from dataclasses import dataclass -from pathlib import Path # Import single router tool and extra modules from mcp_server.tools.single_router_tool import register_single_router_tool @@ -258,7 +257,7 @@ async def execute_tool(self, name: str, arguments: Dict[str, Any], user: str = " logger.info(f"[MCP] result: isError={result.get('isError', False)}, elapsed={elapsed:.2f}s") return result - except asyncio.TimeoutError as e: + except asyncio.TimeoutError: logger.error(f"[MCP] TIMEOUT: tool={name} after {timeout}s") return { 'content': [{'type': 'text', 'text': f'Tool execution timed out after {timeout}s: {name}'}], diff --git a/src/mcp_server/tools/remote_ssh_tools.py b/src/mcp_server/tools/remote_ssh_tools.py index 6f39b5c..4cf61a9 100644 --- a/src/mcp_server/tools/remote_ssh_tools.py +++ b/src/mcp_server/tools/remote_ssh_tools.py @@ -3,7 +3,6 @@ import json import logging import time -import base64 from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional diff --git a/src/mcp_server/tools/ssh_client.py b/src/mcp_server/tools/ssh_client.py index f3be5bd..136f0de 100644 --- a/src/mcp_server/tools/ssh_client.py +++ b/src/mcp_server/tools/ssh_client.py @@ -1,16 +1,13 @@ """SSH client for secure connections to experimental VPS.""" import asyncio import logging -import os -import re import time from dataclasses import dataclass, field from typing import Optional, List, Dict, Any, Tuple from pathlib import Path -import json import asyncssh -from asyncssh import SSHClientConnection, SSHClientProcess, SSHCompletedProcess +from asyncssh import SSHClientConnection from mcp_server.core.settings import get_settings, SSHSettings @@ -106,7 +103,7 @@ async def connect(self) -> bool: if not key_path.exists(): raise FileNotFoundError(f"SSH key not found: {key_path}") - private_key = await asyncio.to_thread( + _private_key = await asyncio.to_thread( self._load_private_key, key_path, self.settings.private_key_passphrase @@ -241,7 +238,7 @@ async def execute( if is_dangerous and not confirm: return SSHResult( stdout="", - stderr=f"DANGEROUS COMMAND DETECTED\n" + "\n".join(warnings) + "\n\nSet confirm=true to execute.", + stderr="DANGEROUS COMMAND DETECTED\n" + "\n".join(warnings) + "\n\nSet confirm=true to execute.", exit_code=126, # Command cannot execute command=command, duration=time.time() - start_time, diff --git a/test_write.txt b/test_write.txt deleted file mode 100644 index c46023f..0000000 --- a/test_write.txt +++ /dev/null @@ -1 +0,0 @@ -hello123 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2b68060..5d930ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,15 @@ +import os import sys from pathlib import Path +# Test collection imports the FastAPI app, whose production settings correctly +# require an SSH target. Keep tests hermetic without weakening production +# validation or attempting any network connection. +os.environ.setdefault("MCP_SSH__HOST", "127.0.0.1") +os.environ.setdefault("MCP_SSH__USER", "ci-test") +os.environ.setdefault("MCP_HEALTH__SSH_CHECK", "false") + ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" diff --git a/tests/test_http_health.py b/tests/test_http_health.py index 3528e4e..6343a31 100644 --- a/tests/test_http_health.py +++ b/tests/test_http_health.py @@ -1,4 +1,8 @@ import asyncio +import io +import json +import urllib.error +import urllib.request from fastapi.testclient import TestClient @@ -33,7 +37,20 @@ def test_control_health_endpoint_shape() -> None: assert 'checks' in body -def test_http_probe_returns_structured_error_payload() -> None: +def test_http_probe_returns_structured_http_error(monkeypatch) -> None: + def fake_urlopen(request, *, timeout, context): + assert request.full_url == 'http://127.0.0.1:8000/definitely-missing' + assert timeout == 2 + raise urllib.error.HTTPError( + request.full_url, + 404, + 'Not Found', + {}, + io.BytesIO(b'missing'), + ) + + monkeypatch.setattr(urllib.request, 'urlopen', fake_urlopen) + tools = MCPTools(None) result = asyncio.run( tools.execute_tool( @@ -42,13 +59,37 @@ def test_http_probe_returns_structured_error_payload() -> None: user='test', ) ) + assert result['isError'] is True - text = result['content'][0]['text'] - assert '"status_code": 404' in text - assert '"success": false' in text + payload = json.loads(result['content'][0]['text']) + assert payload == { + 'success': False, + 'url': 'http://127.0.0.1:8000/definitely-missing', + 'status_code': 404, + 'headers': {}, + 'body': 'missing', + 'truncated': False, + } + +def test_service_control_status_returns_structured_payload(monkeypatch) -> None: + class FakeProcess: + returncode = 0 + + async def communicate(self): + return b'active (running)\n', b'' + + def kill(self): + raise AssertionError('kill must not be called on a successful status probe') + + async def fake_create_subprocess_exec(*cmd, **kwargs): + assert cmd == ('systemctl', 'status', 'mcp-server') + assert kwargs['stdout'] is asyncio.subprocess.PIPE + assert kwargs['stderr'] is asyncio.subprocess.PIPE + return FakeProcess() + + monkeypatch.setattr(asyncio, 'create_subprocess_exec', fake_create_subprocess_exec) -def test_service_control_status_returns_structured_payload() -> None: tools = MCPTools(None) result = asyncio.run( tools.execute_tool( @@ -57,7 +98,15 @@ def test_service_control_status_returns_structured_payload() -> None: user='test', ) ) + assert result['isError'] is False - text = result['content'][0]['text'] - assert '"service": "mcp-server"' in text - assert '"action": "status"' in text + payload = json.loads(result['content'][0]['text']) + assert payload == { + 'success': True, + 'action': 'status', + 'service': 'mcp-server', + 'exit_code': 0, + 'output': 'active (running)\n', + 'error': '', + 'mode': 'sync', + } diff --git a/tmp.txt b/tmp.txt deleted file mode 100644 index b6fc4c6..0000000 --- a/tmp.txt +++ /dev/null @@ -1 +0,0 @@ -hello \ No newline at end of file