Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package mcp-redmine for openSUSE:Factory checked in at 2026-09-08 16:58:23 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/mcp-redmine (Old) and /work/SRC/openSUSE:Factory/.mcp-redmine.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "mcp-redmine" Tue Sep 8 16:58:23 2026 rev:3 rq:1376343 version:2026.9.5.185311 Changes: -------- --- /work/SRC/openSUSE:Factory/mcp-redmine/mcp-redmine.changes 2026-08-21 16:55:02.754621523 +0200 +++ /work/SRC/openSUSE:Factory/.mcp-redmine.new.1265/mcp-redmine.changes 2026-09-08 17:02:02.290802679 +0200 @@ -1,0 +2,16 @@ +Tue Sep 8 09:30:19 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to version 2026.9.5.185311: + * Refuse model-controlled API paths that escape REDMINE_URL + so the API key is not sent to an arbitrary host + * Add streamable-http transport (stdio remains the default) + and keep legacy SSE for old clients + * REDMINE_READ_ONLY=1 refuses non-GET requests + * REDMINE_CA_BUNDLE for a private CA + * redmine_attachment_image tool returns image bytes + * Persistent httpx.Client with keepalive pooling + * Ship and run the pytest suite +- Add mcp-redmine-fastmcp-1.x.patch: keep running on Factory + python-mcp 1.28 FastMCP (MCPServer is the 2.x SDK) + +------------------------------------------------------------------- Old: ---- mcp_redmine-2026.8.1.2543.tar.gz New: ---- mcp-redmine-fastmcp-1.x.patch mcp_redmine-2026.9.5.185311.tar.gz ----------(New B)---------- New: * Ship and run the pytest suite - Add mcp-redmine-fastmcp-1.x.patch: keep running on Factory python-mcp 1.28 FastMCP (MCPServer is the 2.x SDK) ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ mcp-redmine.spec ++++++ --- /var/tmp/diff_new_pack.RpHtv8/_old 2026-09-08 17:02:02.945830058 +0200 +++ /var/tmp/diff_new_pack.RpHtv8/_new 2026-09-08 17:02:02.947830142 +0200 @@ -20,18 +20,21 @@ # it against the distribution's primary Python flavour. %define pythons %{primary_python} Name: mcp-redmine -Version: 2026.8.1.2543 +Version: 2026.9.5.185311 Release: 0 Summary: Model Context Protocol server for Redmine License: MPL-2.0 URL: https://github.com/runekaagaard/mcp-redmine Source: https://files.pythonhosted.org/packages/source/m/mcp_redmine/mcp_redmine-%{version}.tar.gz +# PATCH-FIX-OPENSUSE mcp-redmine-fastmcp-1.x.patch [email protected] -- Factory python-mcp is still 1.28 FastMCP +Patch0: mcp-redmine-fastmcp-1.x.patch BuildRequires: %{primary_python}-PyYAML >= 6.0.2 BuildRequires: %{primary_python}-hatchling BuildRequires: %{primary_python}-httpx >= 0.28.1 BuildRequires: %{primary_python}-mcp >= 1.9.0 BuildRequires: %{primary_python}-openapi-core >= 0.19.4 BuildRequires: %{primary_python}-pip +BuildRequires: %{primary_python}-pytest BuildRequires: %{primary_python}-wheel BuildRequires: fdupes BuildRequires: python-rpm-macros @@ -63,10 +66,7 @@ %fdupes %{buildroot}%{python_sitelib} %check -# server.py reads REDMINE_URL/REDMINE_API_KEY at import time; provide dummies -# for this smoke-import test (upstream ships no test suite) -REDMINE_URL=http://localhost REDMINE_API_KEY=dummy \ - PYTHONPATH=%{buildroot}%{python_sitelib} python%{python_version} -Bc "import mcp_redmine.server" +%pytest tests %files %license LICENSE ++++++ mcp-redmine-fastmcp-1.x.patch ++++++ --- a/mcp_redmine/server.py 2026-09-08 11:31:59.728737460 +0200 +++ b/mcp_redmine/server.py 2026-09-08 11:31:59.732737911 +0200 @@ -2,8 +2,8 @@ from urllib.parse import urljoin import httpx -from mcp.server.mcpserver import MCPServer, Image -from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.server.fastmcp import FastMCP, Image +from mcp.server.fastmcp.utilities.logging import get_logger ### Constants ### @@ -160,7 +160,7 @@ # Tools -mcp = MCPServer("Redmine MCP server", version=VERSION) +mcp = FastMCP("Redmine MCP server") get_logger(__name__).info(f"Starting MCP Redmine version {VERSION}") @mcp.tool(description=""" @@ -288,7 +288,7 @@ ATTACHMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024 @mcp.tool() -def redmine_attachment_image(attachment_id: int) -> Image | str: +def redmine_attachment_image(attachment_id: int): """ Fetch an image attachment (e.g. an inline screenshot like !screenshot.png!) and return it as viewable image content. Use redmine_request on '/issues/{id}.json' with params {'include': 'attachments'} to find @@ -338,10 +338,11 @@ parser.add_argument("--port", type=int, default=8000, help="Port for HTTP transports (default: 8000)") args = parser.parse_args() - if args.transport == "stdio": - mcp.run(transport="stdio") - else: - mcp.run(transport=args.transport, host=args.host, port=args.port) + if args.transport != "stdio": + # FastMCP 1.x takes host/port on the constructor/settings, not run() + mcp.settings.host = args.host + mcp.settings.port = args.port + mcp.run(transport=args.transport) if __name__ == "__main__": main() ++++++ mcp_redmine-2026.8.1.2543.tar.gz -> mcp_redmine-2026.9.5.185311.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/.github/workflows/publish-container.yml new/mcp_redmine-2026.9.5.185311/.github/workflows/publish-container.yml --- old/mcp_redmine-2026.8.1.2543/.github/workflows/publish-container.yml 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/.github/workflows/publish-container.yml 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,37 @@ +name: Publish container image + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=match,pattern=v(.*),group=1 + type=raw,value=latest + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/Makefile new/mcp_redmine-2026.9.5.185311/Makefile --- old/mcp_redmine-2026.8.1.2543/Makefile 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/Makefile 2020-02-02 01:00:00.000000000 +0100 @@ -1,51 +1,63 @@ SHELL := /bin/bash .SHELLFLAGS := -ec -PROJECT := "mcp-redmine" # overloads as binary name, too -PACKAGE := "mcp_redmine" +PROJECT := $(shell grep '^name = ' pyproject.toml | cut -d '"' -f2) +PACKAGE := $(shell echo $(PROJECT) | tr '-' '_') +# Pass VERSION explicitly to sub-makes: it is timestamp-based, so a re-evaluation +# in a sub-make would produce a different version than the one committed/tagged. VERSION := $(shell date +%Y.%m.%d.%H%M%S) version-bump: - sed -i "s/mcp-redmine==[0-9.]*\"/mcp-redmine==$(VERSION)\"/g" README.md + sed -i 's/$(PROJECT)==[0-9.]*"/$(PROJECT)==$(VERSION)"/g' README.md sed -i "s/version = \"[^\"]*\"/version = \"$(VERSION)\"/" pyproject.toml sed -i "s/VERSION = \"[^\"]*\"/VERSION = \"$(VERSION)\"/" $(PACKAGE)/server.py version-bump-claude-desktop: - sed -i "s/mcp-redmine==[0-9.]*\"/mcp-redmine==$(VERSION)\"/g" ~/.config/Claude/claude_desktop_config.json + sed -i "s/$(PROJECT)==[0-9.]*\"/$(PROJECT)==$(VERSION)\"/g" ~/.config/Claude/claude_desktop_config.json + +tests-run: + uv run --group dev python -m pytest tests/ -q publish-test: rm -rf dist/* - $(MAKE) version-bump + $(MAKE) version-bump VERSION=$(VERSION) uv build uv publish --token "$$PYPI_TOKEN_TEST" --publish-url https://test.pypi.org/legacy/ git checkout README.md pyproject.toml $(PACKAGE)/server.py -publish-prod: +publish-prod: tests-run rm -rf dist/* - $(MAKE) version-bump - $(MAKE) version-bump-claude-desktop + $(MAKE) version-bump VERSION=$(VERSION) + $(MAKE) version-bump-claude-desktop VERSION=$(VERSION) uv build uv lock uv publish --token "$$PYPI_TOKEN_PROD" git commit -am "Published version $(VERSION) to PyPI" + git tag "v$(VERSION)" git push + git push origin "v$(VERSION)" package-inspect-test: - rm -rf /tmp/test-mcp-redmine - uv venv /tmp/test-mcp-redmine --python 3.12 - source /tmp/test-mcp-redmine/bin/activate && uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ mcp-redmine - tree /tmp/test-mcp-redmine/lib/python3.12/site-packages/$(PACKAGE) - source /tmp/test-mcp-redmine/bin/activate && which $(PROJECT) + rm -rf /tmp/test-$(PROJECT) + uv venv /tmp/test-$(PROJECT) --python 3.12 + source /tmp/test-$(PROJECT)/bin/activate && uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $(PROJECT) + tree /tmp/test-$(PROJECT)/lib/python3.12/site-packages/$(PACKAGE) + source /tmp/test-$(PROJECT)/bin/activate && which $(PROJECT) package-inspect-prod: - rm -rf /tmp/test-mcp-redmine - uv venv /tmp/test-mcp-redmine --python 3.12 - source /tmp/test-mcp-redmine/bin/activate && uv pip install mcp-redmine - tree /tmp/test-mcp-redmine/lib/python3.12/site-packages/$(PACKAGE) - source /tmp/test-mcp-redmine/bin/activate && which $(PROJECT) + rm -rf /tmp/test-$(PROJECT) + uv venv /tmp/test-$(PROJECT) --python 3.12 + source /tmp/test-$(PROJECT)/bin/activate && uv pip install $(PROJECT) + tree /tmp/test-$(PROJECT)/lib/python3.12/site-packages/$(PACKAGE) + source /tmp/test-$(PROJECT)/bin/activate && which $(PROJECT) package-run-test: - uvx --default-index https://test.pypi.org/simple/ --index https://pypi.org/simple/ --from mcp-redmine mcp-redmine + uvx --default-index https://test.pypi.org/simple/ --index https://pypi.org/simple/ --from $(PROJECT) $(PROJECT) package-run-prod: - uvx --from mcp-redmine mcp-redmine \ No newline at end of file + uvx --from $(PROJECT) $(PROJECT) + +debug-constants: + @echo "PROJECT='$(PROJECT)'" + @echo "PACKAGE='$(PACKAGE)'" + @echo "VERSION='$(VERSION)'" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/PKG-INFO new/mcp_redmine-2026.9.5.185311/PKG-INFO --- old/mcp_redmine-2026.8.1.2543/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ -Metadata-Version: 2.4 +Metadata-Version: 2.5 Name: mcp-redmine -Version: 2026.8.1.2543 +Version: 2026.9.5.185311 Summary: A MCP server that connects to your Redmine instance Project-URL: Homepage, https://github.com/runekaagaard/mcp-redmine Project-URL: Issues, https://github.com/runekaagaard/mcp-redmine/issues @@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.10 Requires-Dist: httpx==0.28.1 -Requires-Dist: mcp[cli]==1.25.0 +Requires-Dist: mcp[cli]==2.1.1 Requires-Dist: openapi-core==0.19.4 Requires-Dist: pyyaml==6.0.2 Description-Content-Type: text/markdown @@ -67,7 +67,7 @@ "mcpServers": { "redmine": { "command": "uvx", - "args": ["--from", "mcp-redmine==2026.08.01.002543", + "args": ["--from", "mcp-redmine==2026.09.05.185311", "--refresh-package", "mcp-redmine", "mcp-redmine"], "env": { "REDMINE_URL": "https://your-redmine-instance.example.com", @@ -87,11 +87,16 @@ docker --version ``` -Build docker image: +Pull the published image from GitHub Container Registry: +```bash +docker pull ghcr.io/runekaagaard/mcp-redmine:latest +``` + +Or build the image yourself: ```bash git clone [email protected]:runekaagaard/mcp-redmine.git cd mcp-redmine -docker build -t mcp-redmine . +docker build -t ghcr.io/runekaagaard/mcp-redmine . ``` Add to your `claude_desktop_config.json`: ```json @@ -109,7 +114,7 @@ "-e", "REDMINE_ALLOWED_DIRECTORIES", "-v", "/path/to/instructions.md:/app/INSTRUCTIONS.md", "-v", "/path/to/uploads:/app/uploads", - "mcp-redmine" + "ghcr.io/runekaagaard/mcp-redmine:latest" ], "env": { "REDMINE_URL": "https://your-redmine-instance.example.com", @@ -122,6 +127,23 @@ } ``` +### 3. Other MCP clients and transports + +MCP Redmine works with any MCP client (Claude Code, Cursor, IntelliJ-based IDEs, n8n, etc.) - point your +client at the same command and environment variables as above, following your client's documentation for +registering MCP servers. + +By default the server speaks stdio. For clients or setups that connect over HTTP, start the server with one +of the HTTP transports: + +```bash +# Recommended HTTP transport (serves on http://HOST:PORT/mcp) +mcp-redmine --transport streamable-http --host 0.0.0.0 --port 8000 + +# Legacy SSE transport, for older clients (serves on http://HOST:PORT/sse) +mcp-redmine --transport sse --host 0.0.0.0 --port 8000 +``` + ## Environment Variables | Variable | Required | Default | Description | @@ -133,6 +155,8 @@ | `REDMINE_RESPONSE_FORMAT` | No | `yaml` | Response format: `yaml` or `json`. Controls how API responses are formatted | | `REDMINE_ALLOWED_DIRECTORIES` | For upload/download | (disabled) | **Required for file operations.** Comma-separated list of directories where upload/download are allowed (e.g., `/tmp,/home/user/uploads`). Upload/download are disabled if not set for security | | `REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS` | No | (disabled) | Set to `1` to disable SSL certificate verification. Use only for self-signed certs in trusted environments | +| `REDMINE_CA_BUNDLE` | No | (system CAs) | Path to a custom CA certificate bundle (e.g. `/path/to/ca.crt`) for Redmine instances using a private certificate chain. Prefer this over disabling verification | +| `REDMINE_READ_ONLY` | No | (disabled) | Set to `1` to only allow GET requests. All writing operations (POST/PUT/DELETE, including uploads) are refused | > **Note**: When running via Docker, the `REDMINE_REQUEST_INSTRUCTIONS` > environment variable must point to a **path inside the container**, not a > path on the host machine. > Therefore, if you want to use a local file, you need to **mount it into the > container** at the correct location. @@ -210,6 +234,12 @@ error: "" ``` +- **redmine_attachment_image** + - Fetch an image attachment (e.g. an inline screenshot referenced as `!screenshot.png!` in an issue + description) and return it as viewable image content, letting the LLM actually see it + - Input: `attachment_id` (integer) + - Returns image content on success (max 5 MB, images only), or a YAML error string on failure + - **redmine_download** - Download an attachment from Redmine and save it to a local file - **Requires `REDMINE_ALLOWED_DIRECTORIES` to be set** @@ -279,7 +309,7 @@ ``` ... "command": "uv", -"args": ["run", "--directory", "/path/to/mcp-redmine", "-m", "mcp_redmine.server", "main"], +"args": ["run", "--directory", "/path/to/mcp-redmine", "-m", "mcp_redmine.server"], ... ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/README.md new/mcp_redmine-2026.9.5.185311/README.md --- old/mcp_redmine-2026.8.1.2543/README.md 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/README.md 2020-02-02 01:00:00.000000000 +0100 @@ -49,7 +49,7 @@ "mcpServers": { "redmine": { "command": "uvx", - "args": ["--from", "mcp-redmine==2026.08.01.002543", + "args": ["--from", "mcp-redmine==2026.09.05.185311", "--refresh-package", "mcp-redmine", "mcp-redmine"], "env": { "REDMINE_URL": "https://your-redmine-instance.example.com", @@ -69,11 +69,16 @@ docker --version ``` -Build docker image: +Pull the published image from GitHub Container Registry: +```bash +docker pull ghcr.io/runekaagaard/mcp-redmine:latest +``` + +Or build the image yourself: ```bash git clone [email protected]:runekaagaard/mcp-redmine.git cd mcp-redmine -docker build -t mcp-redmine . +docker build -t ghcr.io/runekaagaard/mcp-redmine . ``` Add to your `claude_desktop_config.json`: ```json @@ -91,7 +96,7 @@ "-e", "REDMINE_ALLOWED_DIRECTORIES", "-v", "/path/to/instructions.md:/app/INSTRUCTIONS.md", "-v", "/path/to/uploads:/app/uploads", - "mcp-redmine" + "ghcr.io/runekaagaard/mcp-redmine:latest" ], "env": { "REDMINE_URL": "https://your-redmine-instance.example.com", @@ -104,6 +109,23 @@ } ``` +### 3. Other MCP clients and transports + +MCP Redmine works with any MCP client (Claude Code, Cursor, IntelliJ-based IDEs, n8n, etc.) - point your +client at the same command and environment variables as above, following your client's documentation for +registering MCP servers. + +By default the server speaks stdio. For clients or setups that connect over HTTP, start the server with one +of the HTTP transports: + +```bash +# Recommended HTTP transport (serves on http://HOST:PORT/mcp) +mcp-redmine --transport streamable-http --host 0.0.0.0 --port 8000 + +# Legacy SSE transport, for older clients (serves on http://HOST:PORT/sse) +mcp-redmine --transport sse --host 0.0.0.0 --port 8000 +``` + ## Environment Variables | Variable | Required | Default | Description | @@ -115,6 +137,8 @@ | `REDMINE_RESPONSE_FORMAT` | No | `yaml` | Response format: `yaml` or `json`. Controls how API responses are formatted | | `REDMINE_ALLOWED_DIRECTORIES` | For upload/download | (disabled) | **Required for file operations.** Comma-separated list of directories where upload/download are allowed (e.g., `/tmp,/home/user/uploads`). Upload/download are disabled if not set for security | | `REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS` | No | (disabled) | Set to `1` to disable SSL certificate verification. Use only for self-signed certs in trusted environments | +| `REDMINE_CA_BUNDLE` | No | (system CAs) | Path to a custom CA certificate bundle (e.g. `/path/to/ca.crt`) for Redmine instances using a private certificate chain. Prefer this over disabling verification | +| `REDMINE_READ_ONLY` | No | (disabled) | Set to `1` to only allow GET requests. All writing operations (POST/PUT/DELETE, including uploads) are refused | > **Note**: When running via Docker, the `REDMINE_REQUEST_INSTRUCTIONS` > environment variable must point to a **path inside the container**, not a > path on the host machine. > Therefore, if you want to use a local file, you need to **mount it into the > container** at the correct location. @@ -192,6 +216,12 @@ error: "" ``` +- **redmine_attachment_image** + - Fetch an image attachment (e.g. an inline screenshot referenced as `!screenshot.png!` in an issue + description) and return it as viewable image content, letting the LLM actually see it + - Input: `attachment_id` (integer) + - Returns image content on success (max 5 MB, images only), or a YAML error string on failure + - **redmine_download** - Download an attachment from Redmine and save it to a local file - **Requires `REDMINE_ALLOWED_DIRECTORIES` to be set** @@ -261,7 +291,7 @@ ``` ... "command": "uv", -"args": ["run", "--directory", "/path/to/mcp-redmine", "-m", "mcp_redmine.server", "main"], +"args": ["run", "--directory", "/path/to/mcp-redmine", "-m", "mcp_redmine.server"], ... ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/mcp_redmine/server.py new/mcp_redmine-2026.9.5.185311/mcp_redmine/server.py --- old/mcp_redmine-2026.8.1.2543/mcp_redmine/server.py 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/mcp_redmine/server.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,12 +2,12 @@ from urllib.parse import urljoin import httpx -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.mcpserver import MCPServer, Image +from mcp.server.mcpserver.utilities.logging import get_logger ### Constants ### -VERSION = "2026.08.01.002543" +VERSION = "2026.09.05.185311" # Load OpenAPI spec current_dir = pathlib.Path(__file__).parent @@ -37,6 +37,30 @@ # SSL verification (disabled only when explicitly set to "1") REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS = os.environ.get('REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS') == '1' +# Custom CA bundle for private certificate chains (path to a ca.crt / bundle file) +REDMINE_CA_BUNDLE = os.environ.get('REDMINE_CA_BUNDLE', '') + +# Read-only mode (enabled when set to "1") - only GET requests are allowed +REDMINE_READ_ONLY = os.environ.get('REDMINE_READ_ONLY') == '1' + +if REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS: + _ssl_verify = False +elif REDMINE_CA_BUNDLE: + _ssl_verify = REDMINE_CA_BUNDLE +else: + _ssl_verify = True + +# Persistent HTTP client — reuses TCP/TLS connections across calls instead of opening a new one each time. +# Using httpx.request() (top-level function) creates a new connection per call, which adds ~2 minutes of +# TLS handshake overhead on each request when connecting to internal/corporate Redmine servers. +# keepalive_expiry=120 keeps connections alive for 2 minutes; the httpx default of 5s means every call +# after a short pause pays a full ~600ms TLS reconnect cost. +_http_client = httpx.Client( + timeout=60.0, + verify=_ssl_verify, + limits=httpx.Limits(max_keepalive_connections=5, keepalive_expiry=120), +) + if "REDMINE_REQUEST_INSTRUCTIONS" in os.environ: with open(os.environ["REDMINE_REQUEST_INSTRUCTIONS"]) as f: REDMINE_REQUEST_INSTRUCTIONS = f.read() @@ -47,16 +71,27 @@ # Core def request(path: str, method: str = 'get', data: dict = None, params: dict = None, content_type: str = 'application/json', content: bytes = None) -> dict: + if REDMINE_READ_ONLY and method.lower() != 'get': + return {"status_code": 0, "body": None, + "error": f"REDMINE_READ_ONLY is enabled: refusing {method.upper()} request"} + headers = { 'X-Redmine-API-Key': REDMINE_API_KEY, 'Content-Type': content_type, **REDMINE_HEADERS } + + # Security: path is model-controlled. urljoin returns absolute URLs in `path` unchanged, which would + # redirect the request (and the API key header) to an arbitrary host. Only ever allow URLs that stay + # under REDMINE_URL (which is normalized to end with '/'), also catching '../' path traversal. url = urljoin(REDMINE_URL, path.lstrip('/')) + if not url.startswith(REDMINE_URL): + return {"status_code": 0, "body": None, + "error": f"Path escapes REDMINE_URL, refusing to send API key to: {url}"} try: - response = httpx.request(method=method.lower(), url=url, json=data, params=params, headers=headers, - content=content, timeout=60.0, verify=not REDMINE_DANGEROUSLY_ACCEPT_INVALID_CERTS) + response = _http_client.request(method=method.lower(), url=url, json=data, params=params, headers=headers, + content=content) response.raise_for_status() body = None @@ -125,7 +160,7 @@ # Tools -mcp = FastMCP("Redmine MCP server") +mcp = MCPServer("Redmine MCP server", version=VERSION) get_logger(__name__).info(f"Starting MCP Redmine version {VERSION}") @mcp.tool(description=""" @@ -249,20 +284,64 @@ except Exception as e: return format_response({"status_code": 0, "body": None, "error": f"{e.__class__.__name__}: {e}"}) +# Max size for images returned inline as tool content (base64 roughly x1.33, so keep this modest) +ATTACHMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024 + [email protected]() +def redmine_attachment_image(attachment_id: int) -> Image | str: + """ + Fetch an image attachment (e.g. an inline screenshot like !screenshot.png!) and return it as viewable + image content. Use redmine_request on '/issues/{id}.json' with params {'include': 'attachments'} to find + attachment ids. + + Args: + attachment_id: The ID of the image attachment to fetch + + Returns: + Image content on success, or a YAML error string on failure + """ + try: + attachment_response = request(f"attachments/{attachment_id}.json", "get") + if attachment_response["status_code"] != 200: + return format_response(attachment_response) + + attachment = attachment_response["body"]["attachment"] + content_type = attachment.get("content_type") or "" + if not content_type.startswith("image/"): + return format_response({ + "status_code": 0, "body": None, + "error": f"Attachment is not an image (content_type: {content_type}). " + "Use redmine_download to save it to disk instead."}) + if attachment.get("filesize", 0) > ATTACHMENT_IMAGE_MAX_BYTES: + return format_response({ + "status_code": 0, "body": None, + "error": f"Image too large ({attachment['filesize']} bytes, max {ATTACHMENT_IMAGE_MAX_BYTES}). " + "Use redmine_download to save it to disk instead."}) + + response = request(f"attachments/download/{attachment_id}/{attachment['filename']}", "get", + content_type="application/octet-stream") + if response["status_code"] != 200 or not response["body"]: + return format_response(response) + + return Image(data=response["body"], format=content_type.removeprefix("image/")) + except Exception as e: + return format_response({"status_code": 0, "body": None, "error": f"{e.__class__.__name__}: {e}"}) + def main(): """Main entry point for the mcp-redmine package.""" import argparse parser = argparse.ArgumentParser(description="MCP Redmine Server") - parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio", - help="Transport type (default: stdio)") - parser.add_argument("--host", default="0.0.0.0", help="Host for SSE transport (default: 0.0.0.0)") - parser.add_argument("--port", type=int, default=8000, help="Port for SSE transport (default: 8000)") + parser.add_argument("--transport", choices=["stdio", "streamable-http", "sse"], default="stdio", + help="Transport type (default: stdio). streamable-http is the recommended HTTP " + "transport, sse is supported for legacy clients.") + parser.add_argument("--host", default="0.0.0.0", help="Host for HTTP transports (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8000, help="Port for HTTP transports (default: 8000)") args = parser.parse_args() - if args.transport == "sse": - mcp.settings.host = args.host - mcp.settings.port = args.port - mcp.run(transport=args.transport) + if args.transport == "stdio": + mcp.run(transport="stdio") + else: + mcp.run(transport=args.transport, host=args.host, port=args.port) if __name__ == "__main__": main() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/pyproject.toml new/mcp_redmine-2026.9.5.185311/pyproject.toml --- old/mcp_redmine-2026.8.1.2543/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 @@ -1,12 +1,12 @@ [project] name = "mcp-redmine" -version = "2026.08.01.002543" +version = "2026.09.05.185311" description = "A MCP server that connects to your Redmine instance" readme = "README.md" requires-python = ">=3.10" dependencies = [ "httpx==0.28.1", - "mcp[cli]==1.25.0", + "mcp[cli]==2.1.1", "openapi-core==0.19.4", "pyyaml==6.0.2", ] @@ -39,6 +39,7 @@ [dependency-groups] dev = [ + "pytest==8.3.4", "build==1.2.2.post1", "hatchling==1.27.0", ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/tests/test_server.py new/mcp_redmine-2026.9.5.185311/tests/test_server.py --- old/mcp_redmine-2026.8.1.2543/tests/test_server.py 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/tests/test_server.py 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,151 @@ +"""Tests for mcp_redmine.server. Run with: make tests-run""" +import os + +# Server module reads config from environment at import time +os.environ.setdefault("REDMINE_URL", "https://redmine.example.org") +os.environ.setdefault("REDMINE_API_KEY", "test-api-key-123") + +import pytest + +from mcp_redmine import server + + +class FakeResponse: + def __init__(self, status_code=200, json_body=None, content=b""): + self.status_code = status_code + self._json_body = json_body + self.content = content if not json_body else b"x" + + def raise_for_status(self): + pass + + def json(self): + if self._json_body is None: + raise ValueError("no json") + return self._json_body + + [email protected] +def capture_requests(monkeypatch): + """Capture outgoing requests instead of hitting the network.""" + calls = [] + + def fake_request(method, url, **kwargs): + calls.append({"method": method, "url": url, **kwargs}) + return FakeResponse(json_body={"ok": True}) + + monkeypatch.setattr(server._http_client, "request", fake_request) + return calls + + +# Security: model-controlled `path` must never escape REDMINE_URL (issue #44) + +def test_normal_path_stays_on_redmine(capture_requests): + result = server.request("issues.json") + assert result["status_code"] == 200 + assert capture_requests[0]["url"] == "https://redmine.example.org/issues.json" + + +def test_leading_slash_path_stays_on_redmine(capture_requests): + server.request("/issues.json") + assert capture_requests[0]["url"] == "https://redmine.example.org/issues.json" + + [email protected]("path", [ + "https://attacker.example/collect", + "http://attacker.example/collect", + "https://redmine.example.org.attacker.example/collect", + "ftp://attacker.example/collect", +]) +def test_escaping_paths_are_refused(capture_requests, path): + result = server.request(path) + assert result["status_code"] == 0 + assert "escapes REDMINE_URL" in result["error"] + assert capture_requests == [] # nothing left the building + + [email protected]("path", [ + "//attacker.example/collect", # neutralized by lstrip('/') + "../../../collect", # dot segments resolved by urljoin, can't climb above the host + "/../collect", +]) +def test_tricky_paths_stay_on_redmine_host(capture_requests, path): + server.request(path) + assert len(capture_requests) == 1 + assert capture_requests[0]["url"].startswith("https://redmine.example.org/") + + +def test_api_key_only_sent_to_redmine(capture_requests): + server.request("issues.json") + assert capture_requests[0]["headers"]["X-Redmine-API-Key"] == "test-api-key-123" + + +# Read-only mode (REDMINE_READ_ONLY) + +def test_read_only_blocks_writes(capture_requests, monkeypatch): + monkeypatch.setattr(server, "REDMINE_READ_ONLY", True) + for method in ["post", "put", "PATCH", "delete"]: + result = server.request("issues.json", method=method) + assert result["status_code"] == 0 + assert "REDMINE_READ_ONLY" in result["error"] + assert capture_requests == [] + + +def test_read_only_allows_get(capture_requests, monkeypatch): + monkeypatch.setattr(server, "REDMINE_READ_ONLY", True) + result = server.request("issues.json", method="get") + assert result["status_code"] == 200 + assert len(capture_requests) == 1 + + +def test_writes_allowed_by_default(capture_requests): + result = server.request("issues.json", method="post", data={"issue": {}}) + assert result["status_code"] == 200 + + +# Tool plumbing + +def test_redmine_request_tool_wraps_insecure_content(capture_requests): + result = server.redmine_request("issues.json") + assert "<insecure-content-" in result + assert "status_code: 200" in result + + +def test_paths_list_returns_spec_paths(): + result = server.format_response(list(server.SPEC["paths"].keys())) + assert "/issues.json" in result + + +def test_attachment_image_rejects_non_image(monkeypatch): + def fake_request(path, method="get", **kwargs): + return {"status_code": 200, "error": "", + "body": {"attachment": {"content_type": "application/pdf", "filename": "a.pdf", "filesize": 10}}} + + monkeypatch.setattr(server, "request", fake_request) + result = server.redmine_attachment_image(1) + assert isinstance(result, str) and "not an image" in result + + +def test_attachment_image_rejects_oversize(monkeypatch): + def fake_request(path, method="get", **kwargs): + return {"status_code": 200, "error": "", + "body": {"attachment": {"content_type": "image/png", "filename": "a.png", + "filesize": server.ATTACHMENT_IMAGE_MAX_BYTES + 1}}} + + monkeypatch.setattr(server, "request", fake_request) + result = server.redmine_attachment_image(1) + assert isinstance(result, str) and "too large" in result + + +def test_attachment_image_returns_image(monkeypatch): + png_bytes = b"\x89PNG\r\n\x1a\nfakepngdata" + + def fake_request(path, method="get", **kwargs): + if path.endswith(".json"): + return {"status_code": 200, "error": "", + "body": {"attachment": {"content_type": "image/png", "filename": "a.png", "filesize": 20}}} + return {"status_code": 200, "error": "", "body": png_bytes} + + monkeypatch.setattr(server, "request", fake_request) + result = server.redmine_attachment_image(1) + assert isinstance(result, server.Image) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_redmine-2026.8.1.2543/uv.lock new/mcp_redmine-2026.9.5.185311/uv.lock --- old/mcp_redmine-2026.8.1.2543/uv.lock 2020-02-02 01:00:00.000000000 +0100 +++ new/mcp_redmine-2026.9.5.185311/uv.lock 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,11 @@ version = 1 revision = 1 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] [[package]] name = "annotated-types" @@ -13,17 +18,16 @@ [[package]] name = "anyio" -version = "4.8.0" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966 } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 }, ] [[package]] @@ -305,12 +309,28 @@ name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", +] sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, ] [[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] name = "hatchling" version = "1.27.0" source = { registry = "https://pypi.org/simple" } @@ -330,9 +350,12 @@ name = "httpcore" version = "1.0.7" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", +] dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } wheels = [ @@ -340,13 +363,44 @@ ] [[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074 }, +] + +[[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore", version = "1.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'" }, + { name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } @@ -355,21 +409,38 @@ ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427 }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382 }, ] [[package]] name = "idna" -version = "3.10" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550 }, ] [[package]] @@ -377,7 +448,7 @@ version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } wheels = [ @@ -385,6 +456,15 @@ ] [[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] name = "isodate" version = "0.7.2" source = { registry = "https://pypi.org/simple" } @@ -537,27 +617,29 @@ [[package]] name = "mcp" -version = "1.25.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, + { name = "sse-starlette", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "sse-starlette", version = "3.4.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "starlette", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387 } +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076 }, + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912 }, ] [package.optional-dependencies] @@ -568,7 +650,7 @@ [[package]] name = "mcp-redmine" -version = "2026.1.13.152335" +version = "2026.8.1.2543" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -581,12 +663,13 @@ dev = [ { name = "build" }, { name = "hatchling" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, - { name = "mcp", extras = ["cli"], specifier = "==1.25.0" }, + { name = "mcp", extras = ["cli"], specifier = "==2.1.1" }, { name = "openapi-core", specifier = "==0.19.4" }, { name = "pyyaml", specifier = "==6.0.2" }, ] @@ -595,6 +678,20 @@ dev = [ { name = "build", specifier = "==1.2.2.post1" }, { name = "hatchling", specifier = "==1.27.0" }, + { name = "pytest", specifier = "==8.3.4" }, +] + +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656 }, ] [[package]] @@ -664,6 +761,18 @@ ] [[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018 }, +] + +[[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } @@ -851,19 +960,6 @@ ] [[package]] -name = "pydantic-settings" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/a2/ad2511ede77bb424f3939e5148a56d968cdc6b1462620d24b2a1f4ab65b4/pydantic_settings-2.8.0.tar.gz", hash = "sha256:88e2ca28f6e68ea102c99c3c401d6c9078e68a5df600e97b43891c34e089500a", size = 83347 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/a9/3b9642025174bbe67e900785fb99c9bfe91ea584b0b7126ff99945c24a0e/pydantic_settings-2.8.0-py3-none-any.whl", hash = "sha256:c782c7dc3fb40e97b238e713c25d26f64314aece2e91abcff592fcac15f71820", size = 30746 }, -] - -[[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } @@ -896,6 +992,23 @@ ] [[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] name = "python-dotenv" version = "1.0.1" source = { registry = "https://pypi.org/simple" } @@ -1138,33 +1251,47 @@ ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "sse-starlette" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] +dependencies = [ + { name = "anyio", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765 }, ] [[package]] name = "sse-starlette" -version = "2.2.1" +version = "3.4.11" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] dependencies = [ - { name = "anyio" }, - { name = "starlette" }, + { name = "anyio", marker = "python_full_version >= '3.14'" }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122 }, ] [[package]] name = "starlette" version = "0.46.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/b6/fb9a32e3c5d59b1e383c357534c63c2d3caa6f25bf3c59dd89d296ecbaec/starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50", size = 2575568 } wheels = [ @@ -1172,6 +1299,21 @@ ] [[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969 }, +] + +[[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } @@ -1220,6 +1362,15 @@ ] [[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660 }, +] + +[[package]] name = "typer" version = "0.21.1" source = { registry = "https://pypi.org/simple" } @@ -1236,11 +1387,11 @@ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, ] [[package]] @@ -1270,7 +1421,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "h11" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 }
