Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
Expand All @@ -45,6 +45,27 @@ jobs:
slug: commit-check/commit-check-mcp
files: ./coverage.xml

lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]

- name: Lint (ruff)
run: python -m ruff check src tests

- name: Type check (mypy)
run: python -m mypy src

build:
runs-on: ubuntu-24.04
steps:
Expand Down
162 changes: 26 additions & 136 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,109 +100,10 @@ uvx commit-check-mcp

> **Tip**: If `uv` is not installed, get it via `curl -LsSf https://astral.sh/uv/install.sh | sh`.

---

### Claude Desktop

```json
{
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

### Claude Code CLI

```json
{
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

Add to your `~/.claude/settings.json` or project-level `.claude/settings.local.json`.

### Cursor

In Cursor, go to **Settings → Cursor Settings → MCP → Add new MCP server** and paste:

| Field | Value |
|---|---|
| **Name** | `commit-check` |
| **Type** | `command` |
| **Command** | `uvx commit-check-mcp` |

Or add to your project's `.cursor/mcp.json`:

```json
{
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

### Windsurf

Add to your `~/.codeium/windsurf/mcp_config.json`:

```json
{
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

### Cline (VS Code)

Add a new MCP server in the Cline extension settings:

```json
{
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

### Continue.dev (VS Code / JetBrains)

Add to your `~/.continue/config.json`:

```json
{
"experimental": {
"mcpServers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
}
```

### Roo Code
### Configure your client

Add to your Roo Code MCP settings:
Every client below launches the same command; only the config file and, for a
few clients, the wrapper key differ. This is the object to register:

```json
{
Expand All @@ -215,40 +116,29 @@ Add to your Roo Code MCP settings:
}
```

### Zed

Add to your `~/.config/zed/settings.json`:

```json
{
"mcp_servers": {
"commit-check": {
"command": "uvx",
"args": ["commit-check-mcp"]
}
}
}
```

### Generic / Any MCP Client

If your client does not support `uvx`, use `pip` and the direct path:

```bash
pip install commit-check-mcp
which commit-check-mcp
```

Then use the absolute path in your config:

```json
{
"mcpServers": {
"commit-check": {
"command": "/path/to/commit-check-mcp"
}
}
}
| Client | Where it goes | Notes |
|---|---|---|
| Claude Code | `claude mcp add commit-check -- uvx commit-check-mcp` | Add `--scope project` to write a shareable `.mcp.json` at the repo root (`--scope user` makes it available in all your projects). You can also commit a `.mcp.json` containing the block above; `"type": "stdio"` may be added inside the server object. MCP servers are **not** configured in `~/.claude/settings.json`. |
| Claude Desktop | macOS `~/Library/Application Support/Claude/claude_desktop_config.json`; Windows `%APPDATA%\Claude\claude_desktop_config.json` | Block above as-is; restart Claude Desktop. |
| Cursor | project `.cursor/mcp.json` or global `~/.cursor/mcp.json` | Block above as-is (or **Settings → Cursor Settings → MCP → Add new MCP server** with command `uvx commit-check-mcp`). |
| VS Code (Copilot agent mode) | `.vscode/mcp.json` | **Different key**: `{"servers": {"commit-check": {"type": "stdio", "command": "uvx", "args": ["commit-check-mcp"]}}}` |
| Cline | MCP Servers panel → Configure → `cline_mcp_settings.json` (check your client's docs) | Block above as-is. |
| Roo Code | project `.roo/mcp.json` or global `mcp_settings.json` (**Edit Global MCP**) | Block above as-is; optional `"alwaysAllow": [...]`. |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` (check your client's docs) | Block above as-is. |
| Continue | `config.yaml` (or a file in `.continue/mcpServers/`) | **YAML list** under `mcpServers:`, see below. Continue also picks up the JSON block above when dropped into `.continue/mcpServers/`. |
| Zed | `~/.config/zed/settings.json` | **Different key**: `{"context_servers": {"commit-check": {"command": "uvx", "args": ["commit-check-mcp"]}}}` |
| Anything else | your client's MCP config | If the client cannot run `uvx`: `pip install commit-check-mcp`, then set `"command"` to the absolute path of the installed binary and drop `args`. Find it with `which commit-check-mcp` (macOS/Linux), `where commit-check-mcp` (Windows cmd) or `Get-Command commit-check-mcp \| Select-Object -ExpandProperty Source` (PowerShell). |

Continue's `config.yaml` entry in full (`name`, `version` and `schema` are required by Continue; drop them if you are adding only the `mcpServers` fragment to an existing file, or save this as a standalone file in `.continue/mcpServers/`):

```yaml
name: commit-check
version: 0.0.1
schema: v1
mcpServers:
- name: commit-check
command: uvx
args: ["commit-check-mcp"]
```

## Run Manually
Expand Down
17 changes: 16 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ tracker = "https://github.com/commit-check/commit-check-mcp/issues"
[project.optional-dependencies]
dev = [
"pytest>=9.0.0,<10",
"pytest-cov>=6.0.0,<8"
"pytest-cov>=6.0.0,<8",
"ruff>=0.16,<1",
"mypy>=1.14,<3",
]

[project.scripts]
Expand All @@ -56,3 +58,16 @@ fallback_version = "0.0.0"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]

[tool.mypy]
python_version = "3.10"
warn_unused_ignores = true
packages = ["commit_check_mcp"]
mypy_path = "src"
68 changes: 47 additions & 21 deletions src/commit_check_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from __future__ import annotations

import inspect
import os
import subprocess
import threading
from collections.abc import Callable
from contextlib import contextmanager
from importlib.metadata import version
from pathlib import Path
import inspect
import os
import subprocess
from typing import Annotated, Any, TypeVar

from commit_check import __version__ as commit_check_version
Expand Down Expand Up @@ -76,10 +77,10 @@ def _tool(title: str, *, fetches: bool = False) -> Callable[[_F], _F]:
description, with ``{result_shape}`` replaced by :data:`RESULT_SHAPE`.
"""
annotations = ToolAnnotations(
readOnlyHint=not fetches,
destructiveHint=False,
idempotentHint=True,
openWorldHint=fetches,
read_only_hint=not fetches,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=fetches,
)

def register(fn: _F) -> _F:
Expand Down Expand Up @@ -204,7 +205,10 @@ def _normalize_repo_path(repo_path: str | None) -> Path | None:
if not normalized:
raise ToolError("repo_path cannot be empty when provided")

path = Path(normalized).expanduser().resolve()
path = Path(normalized).expanduser()
if not path.is_absolute():
path = _at_rest_cwd() / path
path = path.resolve()
if not path.exists():
raise ToolError(f"repo_path does not exist: {path}")
if not path.is_dir():
Expand All @@ -219,7 +223,7 @@ def _require_git_repo(repo_path: Path | None) -> None:
``git``; outside a repository those reads come back empty and every rule
passes vacuously, so tools that will consult git call this first.
"""
directory = repo_path if repo_path is not None else Path.cwd()
directory = repo_path if repo_path is not None else _at_rest_cwd()
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
Expand All @@ -245,8 +249,8 @@ def _normalize_config_path(config_path: str | None, repo_path: Path | None) -> s
raise ToolError("config_path cannot be empty when provided")

path = Path(normalized).expanduser()
if not path.is_absolute() and repo_path is not None:
path = repo_path / path
if not path.is_absolute():
path = (repo_path if repo_path is not None else _at_rest_cwd()) / path

resolved = path.resolve()
if not resolved.exists():
Expand All @@ -256,19 +260,41 @@ def _normalize_config_path(config_path: str | None, repo_path: Path | None) -> s
return str(resolved)


# os.chdir is process-global and the SDK runs sync tools on worker threads
# concurrently, so every chdir window is serialised on one lock. Long-term fix:
# pass cwd to git and to the config loader instead of changing directory.
_CWD_LOCK = threading.Lock()


def _at_rest_cwd() -> Path:
"""The process cwd with no chdir window in flight.

A relative ``repo_path`` or ``config_path``, and the git check for a call
without ``repo_path``, must resolve against the directory the server was
started in, not against whatever another worker thread has temporarily
switched to. Taking the lock guarantees no window is open. Call it before
entering :func:`_working_directory`, never inside (the lock is not
re-entrant).
"""
with _CWD_LOCK:
return Path.cwd()


@contextmanager
def _working_directory(repo_path: Path | None):
"""Temporarily switch working directory for repo-relative config and git checks."""
if repo_path is None:
yield
return

original_cwd = Path.cwd()
os.chdir(repo_path)
try:
yield
finally:
os.chdir(original_cwd)
# The lock is held even when repo_path is None: a tool that reads the
# process cwd must not observe another thread's temporary chdir.
with _CWD_LOCK:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if repo_path is None:
yield
return
original_cwd = Path.cwd()
os.chdir(repo_path)
try:
yield
finally:
os.chdir(original_cwd)


def _merge_config(
Expand Down
Loading