Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ __pycache__/
*.egg-info/
*.pyc
.pytest_cache/
.coverage
coverage.xml
htmlcov/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ uv sync # install deps into .venv
uv run pytest # all tests
uv run pytest tests/test_runner.py # single file
uv run pytest -k pattern # filter
uv run pytest --cov --cov-report=term-missing # coverage (branch, ~98%)
uv run basecamp-cli-mcp generate # regenerate src/basecamp_cli_mcp/data/tools.json
uv run basecamp-cli-mcp # run the stdio MCP server
uv build # wheel + sdist into dist/
Expand Down
16 changes: 14 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "basecamp-cli-mcp"
version = "0.7.1"
version = "0.8.0"
description = "MCP server that wraps the basecamp CLI."
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -9,7 +9,10 @@ authors = [{ name = "Matt Brooke-Smith", email = "matt@futureworkshops.com" }]
keywords = ["mcp", "basecamp", "model-context-protocol"]

dependencies = [
"mcp>=1.2",
# server.py uses the mcp 2.0 low-level Server (on_list_tools / on_call_tool
# callbacks). The 1.x decorator API it replaced is gone in 2.0, so the range
# is pinned on both sides.
"mcp>=2,<3",
]

[project.urls]
Expand All @@ -32,7 +35,16 @@ include = ["src/basecamp_cli_mcp", "tests", "README.md", "LICENSE"]
[dependency-groups]
dev = [
"pytest>=8",
"anyio>=4",
"pytest-cov>=5",
]

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

[tool.coverage.run]
source = ["src/basecamp_cli_mcp"]
branch = true

[tool.coverage.report]
exclude_also = ['if __name__ == "__main__":']
91 changes: 60 additions & 31 deletions src/basecamp_cli_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any

import mcp.types as types
from mcp.server import Server
from mcp.server import Server, ServerRequestContext
from mcp.server.stdio import stdio_server

from . import __version__
Expand Down Expand Up @@ -61,39 +61,68 @@ def build_server(
runner = runner or Runner()
by_name = {s["name"]: s for s in specs}

server: Server = Server(name="basecamp", version=__version__, instructions=INSTRUCTIONS)

@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name=s["name"],
description=s["description"],
inputSchema=s["input_schema"],
)
for s in specs
]

@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
spec = by_name.get(name)
if spec is None:
raise ValueError(f"Unknown tool: {name}")
tools = [
types.Tool(
name=s["name"],
description=s["description"],
input_schema=s["input_schema"],
)
for s in specs
]

async def on_list_tools(
ctx: ServerRequestContext[object],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=tools)

async def on_call_tool(
ctx: ServerRequestContext[object],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
# A failing tool is reported as an error *result*, not a JSON-RPC error, so
# the client can show the CLI's own message. This matches how the SDK's own
# MCPServer handles tool exceptions.
try:
data = runner.call(spec, arguments or {})
text = _call_spec(runner, by_name, params.name, params.arguments or {})
except BasecampError as e:
detail = f"\n{e.stderr}" if e.stderr else ""
raise RuntimeError(f"{e}{detail}") from e

if data is None:
text = ""
elif isinstance(data, str):
text = data
else:
text = json.dumps(data, indent=2)
return [types.TextContent(type="text", text=text)]

return server
return _error_result(f"{e}{detail}")
except Exception as e: # noqa: BLE001 - surfaced to the client as tool output
return _error_result(str(e))
return types.CallToolResult(content=[types.TextContent(type="text", text=text)])

return Server(
name="basecamp",
version=__version__,
instructions=INSTRUCTIONS,
on_list_tools=on_list_tools,
on_call_tool=on_call_tool,
)


def _call_spec(
runner: Runner,
by_name: dict[str, dict[str, Any]],
name: str,
arguments: dict[str, Any],
) -> str:
spec = by_name.get(name)
if spec is None:
raise ValueError(f"Unknown tool: {name}")
data = runner.call(spec, arguments)
if data is None:
return ""
if isinstance(data, str):
return data
return json.dumps(data, indent=2)


def _error_result(text: str) -> types.CallToolResult:
return types.CallToolResult(
content=[types.TextContent(type="text", text=text)],
is_error=True,
)


async def run(include: list[str] | None = None, exclude: list[str] | None = None) -> None:
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@
@pytest.fixture
def fixtures() -> Path:
return FIXTURES


@pytest.fixture
def anyio_backend() -> str:
"""Run `@pytest.mark.anyio` tests on asyncio only (no trio dependency)."""
return "asyncio"
110 changes: 110 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Tests for the `basecamp-cli-mcp` entry point.

Mostly about wiring: that flags reach `server.run`, that `generate` writes where
it's told, and that `setup` dispatches. The subcommand bodies are stubbed — the
real generator shells out to the basecamp CLI, and `setup` installs software.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest

from basecamp_cli_mcp import cli


@pytest.fixture
def served(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Capture the kwargs the stdio server would have been started with."""
calls: list[dict[str, Any]] = []

async def fake_run(**kwargs: Any) -> None:
calls.append(kwargs)

monkeypatch.setattr("basecamp_cli_mcp.server.run", fake_run)
return calls


def test_version_flag_prints_version(capsys: pytest.CaptureFixture[str]) -> None:
from basecamp_cli_mcp import __version__

with pytest.raises(SystemExit) as excinfo:
cli.main(["--version"])

assert excinfo.value.code == 0
assert __version__ in capsys.readouterr().out


def test_no_arguments_serves_every_tool(served: list[dict[str, Any]]) -> None:
assert cli.main([]) == 0
assert served == [{"include": None, "exclude": None}]


def test_include_flags_accumulate(served: list[dict[str, Any]]) -> None:
cli.main(["--include", "cards_*", "--include", "todos_*"])
assert served[0]["include"] == ["cards_*", "todos_*"]


def test_exclude_flags_accumulate(served: list[dict[str, Any]]) -> None:
cli.main(["--exclude", "api_*", "--exclude", "webhooks_*"])
assert served[0]["exclude"] == ["api_*", "webhooks_*"]


def test_include_and_exclude_combine(served: list[dict[str, Any]]) -> None:
cli.main(["--include", "todos_*", "--exclude", "todos_trash"])
assert served[0] == {"include": ["todos_*"], "exclude": ["todos_trash"]}


# --- generate ----------------------------------------------------------------


class FakeGenerator:
TOOLS = [{"name": "todos_create", "group": "todos", "action": "create"}]

def generate(self) -> list[dict[str, Any]]:
return self.TOOLS


def test_generate_writes_to_explicit_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator)
out = tmp_path / "nested" / "tools.json"

assert cli.main(["generate", "--output", str(out)]) == 0
assert json.loads(out.read_text(encoding="utf-8")) == FakeGenerator.TOOLS
assert "Wrote 1 tools" in capsys.readouterr().err


def test_generate_defaults_to_the_in_tree_data_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without --output it targets the packaged data dir, not the cwd."""
monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator)
monkeypatch.setattr(cli, "files", lambda package: tmp_path)

assert cli.main(["generate"]) == 0
assert json.loads((tmp_path / "data" / "tools.json").read_text()) == FakeGenerator.TOOLS


def test_generated_file_ends_with_a_newline(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Keeps the committed contract diff-clean."""
monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator)
out = tmp_path / "tools.json"

cli.main(["generate", "--output", str(out)])

assert out.read_text(encoding="utf-8").endswith("}\n]\n")


# --- setup -------------------------------------------------------------------


def test_setup_dispatches_and_returns_its_exit_code(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("basecamp_cli_mcp.setup_cmd.run", lambda: 7)
assert cli.main(["setup"]) == 7
Loading
Loading