diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml new file mode 100644 index 0000000..ab3f8cf --- /dev/null +++ b/.github/workflows/Build.yml @@ -0,0 +1,51 @@ +# Copyright 2025 Diffblue +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Build + +on: + pull_request: + branches: + - "**" + push: + branches: + - "main" + +jobs: + build: + runs-on: ubuntu-small + steps: + - uses: actions/checkout@v5 + + - uses: astral-sh/setup-uv@v7 + with: + version: "0.9.5" + + - name: Install Project Dependencies + run: uv sync --locked --all-extras --all-groups + + - name: Check for Vulnerabilities + run: uv tool run uv-secure --disable-cache + + - name: Run Linter + run: uvx ruff check --preview --output-format=github + + - name: Run Formatter + run: uvx ruff format --preview --output-format=github + + - name: Run Tests with Coverage + run: uv run coverage run -m pytest -v + + - name: Coverage Report + run: uv run coverage report \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..018a074 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class # C extensions @@ -205,3 +205,9 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# PyCharm +.idea + +# Claude Code +.claude/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e535c6f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +/ @pcrane @peterschrammel \ No newline at end of file diff --git a/README.md b/README.md index df45576..394bf51 100644 --- a/README.md +++ b/README.md @@ -1,250 +1,143 @@ # **MCP Server for Diffblue Cover CLI** -This repository provides a **Model Context Protocol (MCP) Server** -for the Diffblue Cover CLI tool (`dcover`), making it -callable and manageable by various AI development environments that -adhere to the MCP specification (like the Gemini CLI). +This repository provides a **Model Context Protocol (MCP) Server** for the Diffblue Cover CLI tool (`dcover`), making +it callable and manageable by various AI development environments that adhere to the MCP specification (like the +Gemini CLI). -## **Core Component: The MCP Server (mcp\_diffblue\_server.py)** +## **Core Component: The MCP Server `covermcp/server.py`** The Python script serves as the universal adapter for the `dcover create` command. -### **Server Logic Overview** - -1. **Input:** Reads a JSON payload from stdin containing the MCP request. -2. **Context Extraction:** Extracts the required `id` and the `working_directory` (usually found in the `params.context` field). -3. **Execution:** Executes the shell command `dcover create` using the extracted working directory as the context (`cwd`). -4. **Output:** Constructs an MCP-compliant JSON response, including the execution status, return_code, stdout, and stderr. -5. **Return:** Writes the final JSON response to stdout. - ## **Prerequisites** Before configuring the server with any host environment, ensure you have the following installed: 1. **Diffblue Cover CLI:** The`dcover` command must be installed and accessible in your system's `PATH`. - * You can verify this by running `dcover version` in your terminal. -2. **Python 3:** The MCP server script is written in Python. + * You can verify this by running `dcover version` in your terminal. +2. **uv:** A Python project and package manager (https://docs.astral.sh/uv/) -## **Tool Integration and Setup** +## **Installing the MCP server** -To use this MCP server, you must provide your host AI environment (e.g., Gemini CLI, Claude Code, Windsurf, Devin) with a configuration that tells it how to invoke the Python script. +The project uses [FastMCP](https://gofastmcp.com/getting-started/welcome) to develop and deploy the MCP server. To +install this server, you can use `uv run fastmcp install claude-code --server-spec main.py` (for example), other +LLM tools are supported out of the box: -### **1. Configuration for Gemini CLI (via Extension Manifest)** +```bash +$ uv run fastmcp install --help +Usage: fastmcp install COMMAND -The Gemini CLI uses a JSON manifest file to register external tools. This process is straightforward and allows the model to recommend or execute the tool command directly. +Install MCP servers in various clients and formats. -#### **Step 1: Create the Extension Manifest** - -Create a file named **diffblue-cover.json** in the same directory as -your Python script. +╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ claude-code Install an MCP server in Claude Code. │ +│ claude-desktop Install an MCP server in Claude Desktop. │ +│ cursor Install an MCP server in Cursor. │ +│ gemini-cli Install an MCP server in Gemini CLI. │ +│ mcp-json Generate MCP configuration JSON for manual installation. │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -{ - "name": "Diffblue Cover", - "command": "dcover_create", - "description": "Generate unit tests using Diffblue Cover CLI.", - "executable": "python", - "args": ["mcp_diffblue_server.py"], - "response_schema": { - "type": "object", - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "return_code": { "type": "number" } - } - } -} -``` -| | Field | Description | -| :---- | :---- | :---- | -| | name | The friendly name of the tool. | -| | command | The actual command/verb you will use in the Gemini CLI (e.g., `/dcover_create`). | -| | executable | The program used to run the script (e.g., python). | -| | args | Arguments passed to the executable (`mcp_diffblue_server.py`). | -| | response\_schema | Defines the structure of the result object returned by the server. | - -#### **Step 2: Install the Extension** - -Assuming both files are in a folder named diffblue-extension, install -it using the Gemini CLI command: -``` -# Navigate to the directory containing your extension files -cd /path/to/diffblue-extension - -# Install the extension using its JSON manifest file -gemini extensions install ./diffblue-cover.json -``` - -#### **Usage** - -You can now instruct the model to use the tool in your chat prompt: -"I need unit tests for the current working directory. Please run the Diffblue Cover tool." -The Gemini CLI will execute the tool command `/dcover\_create`. -### 2. Configuration for Claude Code +This command will install the MCP server for _all_ projects, which you may not want. If this is the case, then you can +be targeted in your installation if you use the `mcp-json` option to augment a `.mcp.json` file in the project: -Claude Code supports external tools through MCP-compatible manifest files. By registering Diffblue Cover as a tool, you can have Claude invoke it directly during coding sessions. - -#### Step 1: Create the MCP Tool Manifest - -Create a file named **diffblue-cover.mcp.json** in the same directory as your Python script: - -``` +```json { - "name": "Diffblue Cover", - "command": "dcover_create", - "description": "Generate unit tests using Diffblue Cover CLI.", - "executable": "python", - "args": ["mcp_diffblue_server.py"], - "response_schema": { - "type": "object", - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "return_code": { "type": "number" } + "mcpServers": { + "Diffblue Cover": { + "command": "uv", + "args": [ + "run", + "--with", + "fastmcp", + "fastmcp", + "run", + "/path/to/cover-mcp/main.py" + ] } } } ``` -| Field | Description | -| :---- | :---- | -| **name** | Friendly name for the tool. | -| **command** | The command name Claude Code will recognize, e.g., `/dcover_create`. | -| **description** | Description shown when Claude suggests or executes the tool. | -| **executable** | The runtime used to start the MCP server (`python`). | -| **args** | Arguments passed to the executable (`mcp_diffblue_server.py`). | -| **response_schema** | Defines the expected response structure from the Diffblue MCP server. | - -#### Step 2: Register the Tool in Claude Code - -Move your manifest to Claude's local MCP tools directory: -``` -mv diffblue-cover.mcp.json ~/.claude/mcp/tools/ -``` - -#### Step 3: Restart Claude Code - -Restart Claude Code to detect and load the new MCP tool: -``` -claude restart -``` - -#### Usage +This also allows you to specify environment variables. Currently, there are two that you can specify: -Once configured, you can ask Claude Code to generate tests automatically: -> "Generate unit tests for my current project using Diffblue Cover." +* `DIFFBLUE_COVER_CLI` : the location of the installed `dcover` command line +* `DIFFBLUE_COVER_OPTIONS` : use these `dcover` options as well as those supplied by the LLM -Claude Code will execute the `/dcover_create` command and return the results generated by the Diffblue Cover MCP server. +To use these variables in the `.mcp.json` file above, you would do so like this: -### 3. Configuration for Devin - -Devin supports custom MCP tools through manifest files, allowing it to execute external commands such as Diffblue Cover via its MCP infrastructure. - -#### Step 1: Create the MCP Tool Manifest - -Create a file named **diffblue-cover.mcp.json** in the same directory as your Python script: - -``` +```json { - "name": "Diffblue Cover", - "command": "dcover_create", - "description": "Generate unit tests using Diffblue Cover CLI.", - "executable": "python", - "args": ["mcp_diffblue_server.py"], - "response_schema": { - "type": "object", - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "return_code": { "type": "number" } + "mcpServers": { + "Diffblue Cover": { + "command": "uv", + "args": [ + "run", + "--with", + "fastmcp", + "fastmcp", + "run", + "/path/to/cover-mcp/main.py" + ], + "env": { + "DIFFBLUE_COVER_CLI": "/path/to/dcover", + "DIFFBLUE_COVER_OPTIONS": "--verbose --active-profiles=test" + } } } } ``` -| Field | Description | -| :---- | :---- | -| **name** | Human-friendly name for the tool. | -| **command** | Command that Devin will recognize (e.g., `/dcover_create`). | -| **description** | Short summary of the tool’s purpose. | -| **executable** | Runtime used to start the MCP process (`python`). | -| **args** | Arguments passed to the executable (`mcp_diffblue_server.py`). | -| **response_schema** | Specifies the structure of the response returned by the MCP server. | - -#### Step 2: Register the Tool in Devin - -Move your manifest into Devin’s MCP directory: -``` -mv diffblue-cover.mcp.json ~/.devin/mcp/tools/ -``` - -#### Step 3: Restart Devin - -Restart the Devin environment to detect and load your custom MCP tool: -``` -devin restart -``` +This will run the equivalent to `/path/to/dcover --batch create --verbose --active-profiles=test` -#### Usage +**Note:** No attempt is made to disambiguate the options provided options. -Once registered, you can tell Devin: -> "Generate unit tests for my current project using Diffblue Cover." +## **Developmental Notes** -Devin will call the `/dcover_create` MCP tool and return the test output generated by Diffblue Cover. +FastMCP contains a tool called "MCP Inspector" which can be used to interact with the MCP server without needing the +LLM interaction. To run this developmental server, you can use `uv run fastmcp dev`. The configuration lives in the +file `fastmcp.json` which provides (among other things) the entry point for the server. ---- +```bash +$ uv run fastmcp dev --help +Usage: fastmcp dev [OPTIONS] [ARGS] -### 4. Configuration for Windsurf +Run an MCP server with the MCP Inspector for development. -Windsurf also uses MCP-compatible tool manifests to integrate external utilities like Diffblue Cover. - -#### Step 1: Create the MCP Tool Manifest - -Save the same manifest file as **diffblue-cover.mcp.json**: -``` -{ - "name": "Diffblue Cover", - "command": "dcover_create", - "description": "Generate unit tests using Diffblue Cover CLI.", - "executable": "python", - "args": ["mcp_diffblue_server.py"], - "response_schema": { - "type": "object", - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "return_code": { "type": "number" } - } - } -} +╭─ Parameters ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ SERVER-SPEC --server-spec Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json │ +│ --with-editable Directory containing pyproject.toml to install in editable mode (can be used multiple times) │ +│ --with Additional packages to install (can be used multiple times) │ +│ --inspector-version Version of the MCP Inspector to use │ +│ --ui-port Port for the MCP Inspector UI │ +│ --server-port Port for the MCP Inspector Proxy server │ +│ --python Python version to use (e.g., 3.10, 3.11) │ +│ --with-requirements Requirements file to install dependencies from │ +│ --project Run the command within the given project directory │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -| Field | Description | -| :---- | :---- | -| **name** | Human-readable tool name displayed in Windsurf. | -| **command** | The command Windsurf will execute (e.g., `/dcover_create`). | -| **description** | Visible text summarizing what the tool does. | -| **executable** | Program used to launch the Diffblue MCP server (`python`). | -| **args** | CLI arguments for the executable (`mcp_diffblue_server.py`). | -| **response_schema** | Output format that Windsurf expects from the MCP server. | +### **Project Layout** -#### Step 2: Register the Tool in Windsurf +The project and the dependencies are managed by `uv`, see the [documentation](https://docs.astral.sh/uv/) for the +usage instructions. -Move or copy the manifest into Windsurf’s MCP tools directory: -``` -mv diffblue-cover.mcp.json ~/.windsurf/mcp/tools/ -``` +### **Running Tests** -#### Step 3: Restart Windsurf +There are unit tests (in the `test` directory) which you can run with `uv run coverage run -m pytest` and then get a +coverage report with `uv run coverage report --omit "test/*"` (python includes the coverage of the test files by +default -- not that useful). -Reload Windsurf to register the new MCP tool: -``` -windsurf restart -``` +### **Linting/Formatting** + +To run the linter, run `uv run ruff check`. If successful, you will see a message "All checks passed!". If not, you +should address the issues picked up. More information can be found at +the [Ruff Linter Documentation](https://docs.astral.sh/ruff/linter/) -#### Usage +To format the code, run `uv run ruff format`, this should be run before committing any changes. More information can be +found at the [Ruff Formatter Documentation](https://docs.astral.sh/ruff/formatter/). -After setup, you can use the tool by prompting: -> "Generate unit tests for my codebase using Diffblue Cover." +## *References* -Windsurf will automatically run the `/dcover_create` MCP command through the Diffblue Cover MCP server, returning results directly in your workspace. +* https://gofastmcp.com/ +* https://docs.astral.sh/uv/ +* https://docs.astral.sh/ruff/ diff --git a/covermcp/__init__.py b/covermcp/__init__.py new file mode 100644 index 0000000..b67a755 --- /dev/null +++ b/covermcp/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Diffblue +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CoverMCP. + +An experimental MCP server for invoking Diffblue Cover. +""" + +__all__ = ["mcp", "create"] + +from covermcp.server import create, mcp diff --git a/covermcp/executor.py b/covermcp/executor.py new file mode 100644 index 0000000..7bee4b6 --- /dev/null +++ b/covermcp/executor.py @@ -0,0 +1,109 @@ +"""Functions for interacting with system process execution.""" + +# Copyright 2025 Diffblue +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import contextlib +import re +import time +from collections.abc import Iterator +from pathlib import Path +from subprocess import PIPE, STDOUT, CalledProcessError, Popen, TimeoutExpired +from typing import Final + +CLEANUP_TIMEOUT: Final[int] = 5 + +ANSI_ESCAPE: Final[re] = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +def execute(command: list[str], working_dir: Path, timeout: int | None) -> Iterator[str]: + """Execute the given command in the working directory with real-time output streaming. + + Executes a command and yields output lines as they are produced. Stdout and stderr + are combined into a single stream. The timeout applies to the entire execution, + not just the final wait. + + Args: + command: The command and arguments to execute (e.g., ['dcover', 'create', '--batch']) + working_dir: The directory to execute the command in + timeout: Maximum time in seconds to wait for command completion, or None for no timeout + + Yields: + str: Output lines from the command (stdout and stderr combined), with trailing + newlines removed. Lines are yielded in real-time as the process produces them. + + Raises: + CalledProcessError: If command returns non-zero exit code + TimeoutExpired: If command exceeds timeout duration + OSError: If command cannot be executed (e.g., executable not found) + + Example: + >>> for line in execute(['echo', 'hello'], Path.cwd(), 10): + ... print(f"Output: {line}") + Output: hello + """ + + process = Popen( + command, + cwd=working_dir, + stdout=PIPE, + stderr=STDOUT, + text=True, + ) + + start_time = time.time() if timeout is not None else None + + try: + for line in process.stdout: + if timeout is not None and (time.time() - start_time) > timeout: + cleanup_process(process) + raise TimeoutExpired(command, timeout) + + yield ANSI_ESCAPE.sub("", line.rstrip("\n\r")) + + if timeout is not None: + elapsed = time.time() - start_time + # noinspection PyTypeChecker + remaining = max(0, timeout - elapsed) + if remaining <= 0: + cleanup_process(process) + raise TimeoutExpired(command, timeout) + process.wait(timeout=remaining) + else: + process.wait() + + if process.returncode != 0: + raise CalledProcessError(process.returncode, command) + + except TimeoutExpired: + cleanup_process(process) + raise + except Exception: + if process.poll() is None: + cleanup_process(process) + raise + + +def cleanup_process(process: Popen[str]): + """Forcefully terminate a subprocess and wait (briefly) for cleanup. + + Kills the process and waits up to CLEANUP_TIMEOUT seconds for termination. + Suppresses timeout exceptions to avoid masking the original error during + cleanup operations. + + Args: + process: The subprocess to terminate. + """ + process.kill() + with contextlib.suppress(TimeoutExpired): + process.wait(timeout=CLEANUP_TIMEOUT) diff --git a/covermcp/server.py b/covermcp/server.py new file mode 100644 index 0000000..440a7de --- /dev/null +++ b/covermcp/server.py @@ -0,0 +1,393 @@ +"""MCP server for Diffblue Cover test generation. + +This module provides a FastMCP server that exposes Diffblue Cover's test generation +capabilities through the Model Context Protocol. It allows LLMs to invoke Diffblue Cover +to automatically generate unit tests for Java projects. + +The server exposes a 'create' tool that wraps the dcover CLI, providing configurable +test generation with options for fuzzing, verification, and batch processing. + +Environment Variables: + DIFFBLUE_COVER_CLI: Path to the dcover executable (optional if dcover is on PATH) + DIFFBLUE_COVER_OPTIONS: Override options for the create command +""" + +# Copyright 2025 Diffblue +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Annotated, Any, Final + +from fastmcp import Context, FastMCP +from fastmcp.exceptions import ToolError + +from covermcp import executor + +# Configuration constants +# These define environment variable names and default values for Diffblue Cover execution. + +DIFFBLUE_COVER_CLI: Final[ + Annotated[str, "The name of the environment variable containing the path to the Diffblue Cover CLI"] +] = "DIFFBLUE_COVER_CLI" + +DIFFBLUE_COVER_OPTIONS: Final[ + Annotated[ + str, + ( + "The name of the environment variable containing options for the create command, " + "overriding those obtained by the LLM" + ), + ] +] = "DIFFBLUE_COVER_OPTIONS" + +DEFAULT_WORKING_DIRECTORY: Final[Annotated[Path, "The path to the root of the project"]] = Path.cwd() +DEFAULT_TIMEOUT: Final[ + Annotated[int, "The default timeout (in seconds) for us to wait for the dcover process to finish"] +] = 600 + +# make the executor function type easier to read and understand +ExecutorFunc: type = Callable[[list[str], Path, int | None], Iterator[str]] + +# module-level to allow for testing the arguments supplied to dcover. +execute: Annotated[ExecutorFunc, "How to execute system commands."] = executor.execute + +# module-level to allow fastmcp to find the configured server. +mcp: Annotated[FastMCP[Any], "The global MCP server"] = FastMCP("DiffblueCover") + + +@mcp.prompt("write tests") +def write_tests() -> list[dict]: + """Provide system prompt for Java unit test writing guidance. + + Establishes LLM context as a Java unit testing expert to improve + test generation quality and suggestions. + + Returns: + list[dict]: System role message defining the assistant's expertise. + """ + return [ + { + "role": "system", + "content": "You are a helpful assistant highly skilled at writing unit tests for java code.", + }, + ] + + +@mcp.resource( + "data://config", + description="Provides the configuration options for creating tests with Diffblue Cover.", + mime_type="application/json", + annotations={"readOnlyHint": True, "idempotentHint": True}, +) +def create_options() -> dict: + """Provide comprehensive dcover CLI option documentation. + + Exposes 50+ dcover configuration options covering test frameworks, + mocking, coverage, build systems, and Spring configurations. Enables + LLMs to discover and use dcover features intelligently. + + Returns: + dict: Mapping of CLI option names to descriptions. Keys can be + passed directly to dcover via the create tool's args parameter. + + Note: + Exposed at URI "data://config". Marked read-only and idempotent + for efficient caching. + """ + return { + "--active-profiles": "The comma separated list of profiles to use where creating Spring tests. Not providing a " + "value will use the default profile.", + "--allow-jni": "The comma separated list of additional JNI library name prefixes that should be usable within " + "the sandbox when creating and evaluating tests. JNI library names are the strings supplied in " + "calls to System.loadLibrary(...). By default only JDK provided libraries are allowed. ", + "--annotate-suppress-warnings": "Adds the @SuppressWarning annotation to methods with the given tags, for " + 'example: "--annotate-suppress-warnings=unused,rawtypes", would produce ' + '@SuppressWarnings({"unused","rawtypes"})', + "--batch": "Do not display progress bars. Automatically enabled when environment variable CI=true, or when " + "using the Cover MCP server.", + "--class-name-template=