diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 76181b2b..21cad412 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + fetch-depth: 0 # Publish underlying PyPI package: - name: Set up Python @@ -27,6 +29,18 @@ jobs: python -m pip install --upgrade pip pip install build twine + - name: Verify release versions + run: | + PACKAGE_VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + MANIFEST_VERSION=$(jq -r '.version' server.json) + REGISTRY_PACKAGE_VERSION=$(jq -r '.packages[0].version' server.json) + test "$PACKAGE_VERSION" = "$MANIFEST_VERSION" || { echo "Package and registry versions differ"; exit 1; } + test "$PACKAGE_VERSION" = "$REGISTRY_PACKAGE_VERSION" || { echo "Registry package version differs"; exit 1; } + if [ "$GITHUB_REF_TYPE" = "tag" ]; then + test "$GITHUB_REF_NAME" = "v$PACKAGE_VERSION" || { echo "Tag and package versions differ"; exit 1; } + git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { echo "Release tag is not on main"; exit 1; } + fi + - name: Build package run: python -m build @@ -46,15 +60,5 @@ jobs: - name: Authenticate to MCP Registry run: ./mcp-publisher login github-oidc - - name: Set version in server.json - run: | - VERSION=${GITHUB_REF#refs/tags/v} - # If version is empty (manual trigger without tag), use version from pyproject.toml - if [ -z "$VERSION" ]; then - VERSION=$(grep -E '^version\s*=\s*"[^"]+"' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - fi - echo "Publishing version: $VERSION" - jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json - - name: Publish server to MCP Registry run: ./mcp-publisher publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 86422577..2954540b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to the mcpbridge-wrapper project will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-09-22 + +### Changed + +- Continue the legacy MCP `initialize` handshake for clients that have not adopted the sessionless 2026-07-28 protocol. The modern protocol implementation remains on a separate branch. +- Require release tag, Python package, and MCP Registry manifest versions to agree before publication. + +### Fixed + +- Broker mode now applies the same method-aware Xcode response repair as direct mode, including missing `structuredContent` in tool results and non-tool error normalization. + ## [0.4.5] - 2026-07-02 ### Added @@ -141,6 +152,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ✅ Support for all 20 Xcode MCP tools - ✅ Configuration examples for Cursor, Claude Code, and Codex CLI +[0.5.0]: https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.5.0 [0.4.5]: https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.4.5 [0.4.4]: https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.4.4 [0.4.3]: https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.4.3 diff --git a/PUBLISHING.md b/PUBLISHING.md index 1527e299..4023c901 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -74,10 +74,10 @@ Use the helper to update all required version fields together: ```bash # Preview changes only -python scripts/publish_helper.py 0.4.0 --dry-run +python scripts/publish_helper.py 0.5.0 --dry-run # Apply changes -python scripts/publish_helper.py 0.4.0 +python scripts/publish_helper.py 0.5.0 ``` Or via Makefile: @@ -96,24 +96,27 @@ It also prints the next release commands (`git add`, `git commit`, `git tag`, `g ### Automated Publishing (Recommended) -1. **Update version** (recommended: helper script): +1. **Update version on a release branch** (recommended: helper script): ```bash - python scripts/publish_helper.py 0.4.0 + python scripts/publish_helper.py 0.5.0 ``` -2. **Commit and push**: +2. **Commit, push, and merge a PR into `main` after CI passes**: ```bash git add pyproject.toml server.json - git commit -m "Bump version to 0.4.0" + git commit -m "Bump version to 0.5.0" git push ``` -3. **Create and push a tag**: +3. **Create and push a tag on the verified merge commit in `main`**: ```bash - git tag v0.4.0 - git push origin v0.4.0 + git tag v0.5.0 + git push origin v0.5.0 ``` + The publish workflow rejects tags that do not match package and registry + manifest versions or are not ancestors of `main`. + 4. **Watch the workflow run**: - Go to GitHub Actions tab - The workflow will automatically publish to PyPI and MCP Registry diff --git a/README.md b/README.md index 000f6113..c556e283 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ -[![Version](https://img.shields.io/badge/version-0.4.5-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.4.5) +[![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.5.0) [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -14,6 +14,12 @@ A Python wrapper that makes Xcode 26.3's MCP bridge compatible with Cursor and other strict MCP-spec-compliant clients. +Version `0.5.x` uses the legacy MCP `initialize` handshake. It is intended for +clients that still use that protocol; it does not implement the sessionless +MCP 2026-07-28 wire format. The experimental modern implementation is kept on +a separate branch. Pin `mcpbridge-wrapper==0.5.0` in `uvx --from` if your client +must stay on this protocol when newer major versions become available. + ## The Problem Xcode's `mcpbridge` returns tool responses in the `content` field but omits the required `structuredContent` field when a tool declares an `outputSchema`. According to the MCP specification, when `outputSchema` is declared, responses **must** include `structuredContent`. diff --git a/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md b/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md index c9c5031f..0a7c6712 100644 --- a/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md +++ b/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md @@ -3,12 +3,16 @@ A Python wrapper that enables external AI agents to connect to Xcode via the Model Context Protocol (MCP). +Version `0.5.x` uses the legacy MCP `initialize` handshake. It does not +implement the sessionless MCP 2026-07-28 wire format. Pin +`mcpbridge-wrapper==0.5.0` when the client must stay on this protocol. + ## Source Code [https://github.com/SoundBlaster/XcodeMCPWrapper](https://github.com/SoundBlaster/XcodeMCPWrapper) -[![Version](https://img.shields.io/badge/version-0.4.5-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.4.5) +[![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.5.0) ## Overview diff --git a/pyproject.toml b/pyproject.toml index 6e4a43ec..09cbd7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcpbridge-wrapper" -version = "0.4.5" +version = "0.5.0" description = "Python wrapper for xcrun mcpbridge that adds structuredContent to MCP responses" readme = "README.md" requires-python = ">=3.9" diff --git a/server.json b/server.json index 9c664693..0955142f 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.SoundBlaster/xcode-mcpbridge-wrapper", "title": "Xcode: MCP Bridge Wrapper for Cursor", "description": "Python wrapper that makes Xcode's MCP bridge compatible with strict MCP clients like Cursor", - "version": "0.4.5", + "version": "0.5.0", "repository": { "url": "https://github.com/SoundBlaster/XcodeMCPWrapper", "source": "github" @@ -13,7 +13,7 @@ { "registryType": "pypi", "identifier": "mcpbridge-wrapper", - "version": "0.4.5", + "version": "0.5.0", "transport": { "type": "stdio" }, diff --git a/src/mcpbridge_wrapper/broker/transport.py b/src/mcpbridge_wrapper/broker/transport.py index d4935392..b5665c6b 100644 --- a/src/mcpbridge_wrapper/broker/transport.py +++ b/src/mcpbridge_wrapper/broker/transport.py @@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any from mcpbridge_wrapper.broker.types import BrokerConfig, BrokerState, ClientSession +from mcpbridge_wrapper.transform import process_response_line if TYPE_CHECKING: from mcpbridge_wrapper.broker.daemon import BrokerDaemon @@ -290,7 +291,6 @@ async def route_upstream_response(self, line: str) -> None: return broker_id: int = raw_id - self._record_tool_response_metrics(broker_id, msg) client_id = broker_id >> _SESSION_SHIFT int_local_id = broker_id & _ID_MASK @@ -303,6 +303,12 @@ async def route_upstream_response(self, line: str) -> None: ) return + method_name = session.pending_methods.pop(broker_id, None) + if method_name is not None: + line = process_response_line(line, method=method_name) + msg = json.loads(line) + self._record_tool_response_metrics(broker_id, msg) + # Restore original request ID via O(1) reverse map and release alias. # Fall back to int_local_id for sessions that pre-populated pending # without going through _process_client_line (e.g. legacy test fixtures). @@ -589,6 +595,8 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: loop = asyncio.get_event_loop() fut: asyncio.Future[str] = loop.create_future() session.pending[broker_id] = fut + if method_name is not None: + session.pending_methods[broker_id] = method_name if method_name == "tools/call" and broker_id is not None and self._metrics is not None: tool_name = self._extract_tool_call_name(msg) @@ -610,6 +618,7 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: ) if broker_id is not None: session.pending.pop(broker_id, None) + session.pending_methods.pop(broker_id, None) self._record_broker_tool_failure( broker_id, error_code=-32001, @@ -637,6 +646,7 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: await self._send_error(session, raw_id, -32001, "Upstream write failed") if broker_id is not None: session.pending.pop(broker_id, None) + session.pending_methods.pop(broker_id, None) self._record_broker_tool_failure( broker_id, error_code=-32001, @@ -687,6 +697,7 @@ async def _send_error( async def _drain_session(self, session: ClientSession) -> None: """Send -32001 error for all pending requests and close the session.""" for broker_id, fut in list(session.pending.items()): + session.pending_methods.pop(broker_id, None) if not fut.done(): fut.cancel() self._record_broker_tool_failure( diff --git a/src/mcpbridge_wrapper/broker/types.py b/src/mcpbridge_wrapper/broker/types.py index 0c519b46..626af78d 100644 --- a/src/mcpbridge_wrapper/broker/types.py +++ b/src/mcpbridge_wrapper/broker/types.py @@ -83,6 +83,8 @@ class ClientSession: connected_at: float writer: asyncio.StreamWriter pending: dict[int, asyncio.Future] = field(default_factory=dict) + # Original method for method-aware response repair in broker mode. + pending_methods: dict[int, str] = field(default_factory=dict) # Maps string original IDs to their local integer alias (for string-ID support) string_id_map: dict[str, int] = field(default_factory=dict) # Maps integer original IDs to their local integer alias (reversible; FU-P13-T11) diff --git a/tests/unit/test_broker_transport.py b/tests/unit/test_broker_transport.py index 6dc5d761..187f85c6 100644 --- a/tests/unit/test_broker_transport.py +++ b/tests/unit/test_broker_transport.py @@ -266,6 +266,75 @@ async def test_initialized_notification_flushes_queued_tools_list_changed( class TestRouteUpstreamTargetedResponse: + @pytest.mark.asyncio + async def test_broker_repairs_tool_result_for_strict_clients(self, tmp_path: Any) -> None: + server = _make_server(tmp_path) + session = _make_session(1) + server._sessions[1] = session + await server._process_client_line( + session, + json.dumps( + { + "jsonrpc": "2.0", + "id": "tool-1", + "method": "tools/call", + "params": {"name": "XcodeListWindows"}, + } + ), + ) + broker_id = next(iter(session.pending)) + + await server.route_upstream_response( + json.dumps( + { + "jsonrpc": "2.0", + "id": broker_id, + "result": {"content": [{"type": "text", "text": '{"windows": []}'}]}, + } + ) + ) + + response = json.loads(session.writer.write.call_args[0][0]) + assert response["id"] == "tool-1" + assert response["result"]["structuredContent"] == {"windows": []} + assert session.pending_methods == {} + + @pytest.mark.asyncio + async def test_broker_normalizes_non_tool_error(self, tmp_path: Any) -> None: + server = _make_server(tmp_path) + session = _make_session(1) + server._sessions[1] = session + await server._process_client_line( + session, + json.dumps( + { + "jsonrpc": "2.0", + "id": 7, + "method": "resources/read", + "params": {"uri": "xcode://missing"}, + } + ), + ) + broker_id = next(iter(session.pending)) + + await server.route_upstream_response( + json.dumps( + { + "jsonrpc": "2.0", + "id": broker_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": "Unknown resource"}], + }, + } + ) + ) + + response = json.loads(session.writer.write.call_args[0][0]) + assert response["id"] == 7 + assert response["error"]["code"] == -32601 + assert session.pending_methods == {} + @pytest.mark.asyncio async def test_integer_id_routed_to_correct_session(self, tmp_path: Any) -> None: server = _make_server(tmp_path)