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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,25 @@ This command will:

```bash
# 1. Update version in both files manually or use the script:
./scripts/release.sh <version> "<commit-message>"
./scripts/release.sh <version> [<summary>]

# Example:
# Examples:
./scripts/release.sh 0.1.2
./scripts/release.sh 0.1.2 "Add new generator features"

# The script handles:
# - Updating version.py and pyproject.toml
# - Constructing the release commit message from the project convention
# - Committing changes (including README.md changelog)
# - Creating and pushing tag
# - Triggering GitHub Actions
```

**Release commit message convention**: `chore: Release fips-agents-cli vX.Y.Z`,
optionally followed by ` — <summary>`. The script always constructs the message
from the version argument; callers only supply the summary, never the full
message. This keeps `git log --grep "Release fips-agents-cli"` reliable.

**Note**: Always update the changelog in README.md before running the script.

See `RELEASE_CHECKLIST.md` for detailed release procedures and troubleshooting.
Expand Down
33 changes: 27 additions & 6 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@
#
# release.sh - Automated release script for fips-agents-cli
#
# Usage: ./scripts/release.sh <version> <commit-message>
# Usage: ./scripts/release.sh <version> [<summary>]
#
# Example: ./scripts/release.sh 0.1.2 "Add new feature for X"
# The release commit message is always constructed from the version using
# the project convention:
#
# chore: Release fips-agents-cli vX.Y.Z
#
# If a summary is provided, it is appended after an em-dash:
#
# chore: Release fips-agents-cli vX.Y.Z — <summary>
#
# Examples:
# ./scripts/release.sh 0.1.2
# ./scripts/release.sh 0.1.2 "Add new generator features"
#

set -e # Exit on error
Expand All @@ -29,14 +40,16 @@ print_info() {
}

# Check arguments
if [ $# -ne 2 ]; then
print_error "Usage: $0 <version> <commit-message>"
echo "Example: $0 0.1.2 \"Add new feature for X\""
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
print_error "Usage: $0 <version> [<summary>]"
echo "Examples:"
echo " $0 0.1.2"
echo " $0 0.1.2 \"Add new generator features\""
exit 1
fi

VERSION=$1
COMMIT_MSG=$2
SUMMARY=${2:-}

# Validate version format (x.y.z)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
Expand All @@ -45,6 +58,14 @@ if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1
fi

# Construct the conventional release commit message.
# Convention: "chore: Release fips-agents-cli vX.Y.Z" with optional " — <summary>".
if [ -n "$SUMMARY" ]; then
COMMIT_MSG="chore: Release fips-agents-cli v${VERSION} — ${SUMMARY}"
else
COMMIT_MSG="chore: Release fips-agents-cli v${VERSION}"
fi

print_info "Preparing release v$VERSION"
echo

Expand Down
39 changes: 39 additions & 0 deletions tests/fixtures/middleware_template/component.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""{{ description }}"""

import mcp.types as mt
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.tool import ToolResult

from src.core.logging import get_logger

log = get_logger("middleware.{{ component_name }}")


class {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware(Middleware):
"""{{ description }}

To activate, add an instance to the middleware=[] list in create_server()
(see src/core/server.py).
"""

async def on_call_tool(
self,
context: MiddlewareContext[mt.CallToolRequestParams],
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
) -> ToolResult:
"""Wrap tool execution with {{ component_name }} logic."""
tool_name = context.message.name

log.debug(f"{{ component_name }} middleware: before {tool_name}")

# TODO: Add pre-execution logic here

try:
result = await call_next(context)
except Exception as e:
log.error(f"{{ component_name }} middleware: error in {tool_name}: {e}")
raise

# TODO: Add post-execution logic here

return result
40 changes: 40 additions & 0 deletions tests/fixtures/middleware_template/test.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Tests for {{ component_name }} middleware."""

import pytest
from unittest.mock import AsyncMock, MagicMock
import mcp.types as mt

from src.middleware.{{ module_path }} import {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware


@pytest.mark.asyncio
async def test_{{ component_name }}_success():
"""Test middleware handles successful execution."""
middleware = {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware()

# Create mock context
context = MagicMock(spec=mt.CallToolRequestParams)
context.message.name = "test_tool"

# Create mock call_next
call_next = AsyncMock(return_value="success_result")

# Execute middleware
result = await middleware.on_call_tool(context, call_next)

assert result == "success_result"
call_next.assert_called_once_with(context)


@pytest.mark.asyncio
async def test_{{ component_name }}_error():
"""Test middleware handles errors."""
middleware = {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware()

context = MagicMock(spec=mt.CallToolRequestParams)
context.message.name = "failing_tool"

call_next = AsyncMock(side_effect=ValueError("Test error"))

with pytest.raises(ValueError, match="Test error"):
await middleware.on_call_tool(context, call_next)
117 changes: 117 additions & 0 deletions tests/test_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,123 @@ def test_generate_middleware_invalid_hook_type(self, runner, mock_mcp_project):
os.chdir(original_cwd)


@pytest.fixture
def mock_mcp_project_with_real_middleware_template(tmp_path):
"""Mock MCP project that uses the real middleware Jinja2 templates as fixtures.

Templates are committed under tests/fixtures/middleware_template/ so the
test runs offline and is not coupled to mcp-server-template's git state.
Refresh the fixtures when the upstream template changes meaningfully.
"""
import shutil
from pathlib import Path

pyproject_content = """
[project]
name = "test-mcp-server"
version = "0.1.0"
dependencies = [
"fastmcp>=3.0.0",
]
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)

for component_dir in ["middleware"]:
(tmp_path / "src" / component_dir).mkdir(parents=True)
(tmp_path / "tests" / component_dir).mkdir(parents=True)

fixture_dir = Path(__file__).parent / "fixtures" / "middleware_template"
generators_dir = tmp_path / ".fips-agents-cli" / "generators" / "middleware"
generators_dir.mkdir(parents=True)
shutil.copy(fixture_dir / "component.py.j2", generators_dir / "component.py.j2")
shutil.copy(fixture_dir / "test.py.j2", generators_dir / "test.py.j2")

return tmp_path


class TestGenerateMiddlewareRealTemplate:
"""Integration tests that render the real v3.x middleware template.

These guard against regressions that would slip past the synthetic
`mock_mcp_project` fixture, which uses a stripped-down template that
doesn't exercise fastmcp imports, class structure, or the real
Jinja2 conditionals.
"""

@pytest.mark.parametrize("hook_type", ["before_tool", "after_tool", "on_error"])
def test_real_template_renders_for_each_hook_type(
self, runner, mock_mcp_project_with_real_middleware_template, hook_type
):
"""Each --hook-type renders valid Python against the real v3.x template."""
import ast
import os

project = mock_mcp_project_with_real_middleware_template
original_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(
generate,
[
"middleware",
f"{hook_type}_mw",
"--description",
f"{hook_type} hook middleware",
"--hook-type",
hook_type,
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output

rendered = project / "src" / "middleware" / f"{hook_type}_mw.py"
assert rendered.exists()

source = rendered.read_text()
ast.parse(source) # raises if invalid Python
assert "from fastmcp.server.middleware import" in source
assert "class " in source and "Middleware(Middleware):" in source
assert "async def on_call_tool" in source

test_file = project / "tests" / "middleware" / f"test_{hook_type}_mw.py"
assert test_file.exists()
ast.parse(test_file.read_text())
finally:
os.chdir(original_cwd)

def test_real_template_renders_without_hook_type(
self, runner, mock_mcp_project_with_real_middleware_template
):
"""Omitting --hook-type still produces a valid generic wrapper (backward compat)."""
import ast
import os

project = mock_mcp_project_with_real_middleware_template
original_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(
generate,
[
"middleware",
"generic_mw",
"--description",
"generic middleware",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output

rendered = project / "src" / "middleware" / "generic_mw.py"
assert rendered.exists()
source = rendered.read_text()
ast.parse(source)
assert "class GenericMwMiddleware(Middleware):" in source
assert "async def on_call_tool" in source
finally:
os.chdir(original_cwd)


class TestGenerateErrorCases:
"""Tests for error handling."""

Expand Down
Loading