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
21 changes: 11 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,31 @@ permissions:
packages: write

env:
UV_VERSION: "0.11.8"
UV_VERSION: "0.11.15"
PYTHON_VERSION: "3.14"

jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
version: ${{ env.UV_VERSION }}
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src

test:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
version: ${{ env.UV_VERSION }}
python-version: ${{ env.PYTHON_VERSION }}
Expand All @@ -52,17 +53,17 @@ jobs:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
Expand All @@ -71,7 +72,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM python:3.14-alpine

RUN apk add --no-cache build-base libffi-dev openssl-dev rust cargo curl wget \
&& pip install --no-cache-dir uv==0.11.8 \
&& pip install --no-cache-dir uv==0.11.15 \
&& addgroup -S redmine \
&& adduser -S -G redmine -u 10001 redmine

Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"mcp[cli]==1.27.0",
"mcp[cli]==1.27.1",
"httpx==0.28.1",
"pydantic==2.13.3",
"starlette==1.0.0",
"uvicorn[standard]==0.46.0",
"pydantic==2.13.4",
"starlette==1.1.0",
"uvicorn[standard]==0.48.0",
]

[project.urls]
Expand All @@ -61,8 +61,8 @@ dev = [
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"respx==0.23.1",
"ruff==0.15.12",
"mypy==1.20.2",
"ruff==0.15.14",
"mypy==2.1.0",
]

[tool.ruff]
Expand Down
4 changes: 1 addition & 3 deletions src/redmine_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,7 @@ def _raise_for_status(resp: httpx.Response) -> None:
raise RedmineError(status, message, errors=errors, body=body)


_current_client: ContextVar[RedmineClient | None] = ContextVar(
"redmine_client", default=None
)
_current_client: ContextVar[RedmineClient | None] = ContextVar("redmine_client", default=None)


def set_current_client(client: RedmineClient | None) -> object:
Expand Down
4 changes: 1 addition & 3 deletions src/redmine_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ def load_base_url() -> str:
raise RuntimeError("REDMINE_URL env var is required")
parsed = urlsplit(raw)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise RuntimeError(
f"REDMINE_URL must be an absolute http(s) URL, got: {raw!r}"
)
raise RuntimeError(f"REDMINE_URL must be an absolute http(s) URL, got: {raw!r}")
return f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"


Expand Down
1 change: 0 additions & 1 deletion src/redmine_mcp/tools/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,3 @@ async def get_project(
params = {"include": csv(include)}
data = await client().get_json(f"/projects/{id}.json", params=params)
return data.get("project", data)

4 changes: 1 addition & 3 deletions src/redmine_mcp/tools/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,4 @@ async def list_queries(
"""List saved queries visible to the current user. Use a query's
id with list_issues(query_id=...) to apply it (Redmine accepts
query_id as a filter on /issues.json)."""
return await client().paginate(
"/queries.json", "queries", limit=limit, offset=offset
)
return await client().paginate("/queries.json", "queries", limit=limit, offset=offset)
4 changes: 1 addition & 3 deletions tests/test_tools_attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,7 @@ async def test_download_attachment_size_cap_exceeded(mcp) -> None:
@respx.mock
async def test_download_attachment_missing_content_url(mcp) -> None:
respx.get(f"{BASE_URL}/attachments/4.json").mock(
return_value=httpx.Response(
200, json={"attachment": {"id": 4, "filename": "x.txt"}}
)
return_value=httpx.Response(200, json={"attachment": {"id": 4, "filename": "x.txt"}})
)
with pytest.raises(Exception, match="content_url"):
await call(mcp, "download_attachment", id=4)
Expand Down
4 changes: 1 addition & 3 deletions tests/test_tools_enumerations.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ async def test_list_time_entry_activities(mcp) -> None:
@respx.mock
async def test_list_document_categories(mcp) -> None:
respx.get(f"{BASE_URL}/enumerations/document_categories.json").mock(
return_value=httpx.Response(
200, json={"document_categories": [{"id": 1, "name": "Docs"}]}
)
return_value=httpx.Response(200, json={"document_categories": [{"id": 1, "name": "Docs"}]})
)
out = await call(mcp, "list_document_categories")
assert out["items"] == [{"id": 1, "name": "Docs"}]
10 changes: 4 additions & 6 deletions tests/test_tools_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,17 @@
@respx.mock
async def test_list_files(mcp) -> None:
respx.get(f"{BASE_URL}/projects/p/files.json").mock(
return_value=httpx.Response(
200, json={"files": [{"id": 1, "filename": "spec.pdf"}]}
)
return_value=httpx.Response(200, json={"files": [{"id": 1, "filename": "spec.pdf"}]})
)
out = await call(mcp, "list_files", project_id="p")
assert out["items"] == [{"id": 1, "filename": "spec.pdf"}]


@respx.mock
async def test_upload_file_two_step(mcp) -> None:
upload_route = respx.post(
f"{BASE_URL}/uploads.json", params={"filename": "spec.pdf"}
).mock(return_value=httpx.Response(201, json={"upload": {"token": "tok.123"}}))
upload_route = respx.post(f"{BASE_URL}/uploads.json", params={"filename": "spec.pdf"}).mock(
return_value=httpx.Response(201, json={"upload": {"token": "tok.123"}})
)
attach_route = respx.post(f"{BASE_URL}/projects/p/files.json").mock(
return_value=httpx.Response(201, json={"file": {"id": 9}})
)
Expand Down
8 changes: 2 additions & 6 deletions tests/test_tools_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
@respx.mock
async def test_list_groups(mcp) -> None:
respx.get(f"{BASE_URL}/groups.json").mock(
return_value=httpx.Response(
200, json={"groups": [{"id": 1, "name": "Backend"}]}
)
return_value=httpx.Response(200, json={"groups": [{"id": 1, "name": "Backend"}]})
)
out = await call(mcp, "list_groups")
assert out["items"] == [{"id": 1, "name": "Backend"}]
Expand Down Expand Up @@ -62,9 +60,7 @@ async def test_delete_group(mcp) -> None:

@respx.mock
async def test_add_user_to_group(mcp) -> None:
route = respx.post(f"{BASE_URL}/groups/1/users.json").mock(
return_value=httpx.Response(204)
)
route = respx.post(f"{BASE_URL}/groups/1/users.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "add_user_to_group", group_id=1, user_id=42)
assert out == {"group_id": 1, "user_id": 42, "added": True}
assert b'"user_id":42' in route.calls.last.request.read()
Expand Down
8 changes: 2 additions & 6 deletions tests/test_tools_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,15 @@ async def test_create_issue_strips_none(mcp) -> None:

@respx.mock
async def test_update_issue_with_notes(mcp) -> None:
route = respx.put(f"{BASE_URL}/issues/3.json").mock(
return_value=httpx.Response(204)
)
route = respx.put(f"{BASE_URL}/issues/3.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "update_issue", id=3, notes="ack")
assert out == {"id": 3, "updated": True}
assert b'"notes":"ack"' in route.calls.last.request.read()


@respx.mock
async def test_add_issue_note_uses_put(mcp) -> None:
route = respx.put(f"{BASE_URL}/issues/9.json").mock(
return_value=httpx.Response(204)
)
route = respx.put(f"{BASE_URL}/issues/9.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "add_issue_note", id=9, notes="hi", private=True)
assert out["noted"] is True
body = route.calls.last.request.read()
Expand Down
4 changes: 1 addition & 3 deletions tests/test_tools_memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ async def test_add_project_member_requires_one_target(mcp) -> None:

@respx.mock
async def test_update_membership(mcp) -> None:
route = respx.put(f"{BASE_URL}/memberships/12.json").mock(
return_value=httpx.Response(204)
)
route = respx.put(f"{BASE_URL}/memberships/12.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "update_membership", id=12, role_ids=[3, 5])
assert out == {"id": 12, "updated": True}
assert b'"role_ids":[3,5]' in route.calls.last.request.read()
Expand Down
4 changes: 1 addition & 3 deletions tests/test_tools_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ async def test_list_trackers(mcp) -> None:
@respx.mock
async def test_list_issue_categories_for_project(mcp) -> None:
respx.get(f"{BASE_URL}/projects/p/issue_categories.json").mock(
return_value=httpx.Response(
200, json={"issue_categories": [{"id": 3, "name": "Backend"}]}
)
return_value=httpx.Response(200, json={"issue_categories": [{"id": 3, "name": "Backend"}]})
)
out = await call(mcp, "list_issue_categories", project_id="p")
assert out["items"] == [{"id": 3, "name": "Backend"}]
Expand Down
4 changes: 1 addition & 3 deletions tests/test_tools_time_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,7 @@ async def test_list_time_entries_only_to(mcp) -> None:

@respx.mock
async def test_update_time_entry(mcp) -> None:
route = respx.put(f"{BASE_URL}/time_entries/8.json").mock(
return_value=httpx.Response(204)
)
route = respx.put(f"{BASE_URL}/time_entries/8.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "update_time_entry", id=8, hours=3.0, comments="rev")
assert out == {"id": 8, "updated": True}
body = route.calls.last.request.read()
Expand Down
4 changes: 1 addition & 3 deletions tests/test_tools_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ async def test_create_version(mcp) -> None:

@respx.mock
async def test_update_version(mcp) -> None:
route = respx.put(f"{BASE_URL}/versions/5.json").mock(
return_value=httpx.Response(204)
)
route = respx.put(f"{BASE_URL}/versions/5.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "update_version", id=5, status="closed")
assert out == {"id": 5, "updated": True}
body = route.calls.last.request.read()
Expand Down
8 changes: 2 additions & 6 deletions tests/test_tools_wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
@respx.mock
async def test_list_wiki_pages(mcp) -> None:
respx.get(f"{BASE_URL}/projects/p/wiki/index.json").mock(
return_value=httpx.Response(
200, json={"wiki_pages": [{"title": "Home"}, {"title": "API"}]}
)
return_value=httpx.Response(200, json={"wiki_pages": [{"title": "Home"}, {"title": "API"}]})
)
out = await call(mcp, "list_wiki_pages", project_id="p")
assert out["items"][0]["title"] == "Home"
Expand Down Expand Up @@ -50,8 +48,6 @@ async def test_create_or_update_wiki_page(mcp) -> None:

@respx.mock
async def test_delete_wiki_page(mcp) -> None:
respx.delete(f"{BASE_URL}/projects/p/wiki/Old.json").mock(
return_value=httpx.Response(204)
)
respx.delete(f"{BASE_URL}/projects/p/wiki/Old.json").mock(return_value=httpx.Response(204))
out = await call(mcp, "delete_wiki_page", project_id="p", title="Old")
assert out == {"project_id": "p", "title": "Old", "deleted": True}
Loading
Loading