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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Add the following to your MCP host configuration (works with Claude Desktop, Win
### Available Resources

- `photoshop://info` - Get Photoshop application information
- `photoshop://document/info` - Get active document information
- `photoshop://document/info` - Get active document information (name, width, height, resolution, layers count). When Photoshop does not report the document dimensions, `width`/`height` are `0.0` and an extra `dimensions_unavailable: true` field marks them as unreadable rather than genuinely zero. `photoshop_open_document` reports the same signal.
- `photoshop://document/layers` - Get layers in the active document

### Available Tools
Expand Down
30 changes: 30 additions & 0 deletions photoshop_mcp_server/ps_adapter/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
# Type variable for generic function
T = TypeVar("T")

#: Sentinel returned by :func:`try_float` when a measurement cannot be read.
UNCONVERTIBLE = object()


def to_float(value: Any, default: float = 0.0) -> float:
"""Convert a Photoshop measurement into a plain float.
Expand Down Expand Up @@ -40,6 +43,33 @@ def to_float(value: Any, default: float = 0.0) -> float:
return default


def try_float(value: Any) -> float | object:
"""Convert a Photoshop measurement into a float, or report it unreadable.

Same conversion rules as :func:`to_float`, but instead of collapsing an
unreadable measurement into a stand-in number it returns the
:data:`UNCONVERTIBLE` sentinel. Callers that must tell a real ``0.0`` apart
from a measurement Photoshop never reported use this and pick their own
fallback afterwards.

The warning log is kept, so an unreadable measurement stays visible in the
server log whether or not the caller surfaces the condition to the client.

Args:
value: The raw value returned by Photoshop.

Returns:
float | object: The measurement as a float, or ``UNCONVERTIBLE``.

"""
raw = getattr(value, "value", value)
try:
return float(raw)
except (TypeError, ValueError):
logger.warning(f"Could not convert {raw!r} to float; dimensions unavailable")
return UNCONVERTIBLE


def with_retry(
max_attempts: int = 3, wait_seconds: float = 2.0
) -> Callable[[Callable[..., T]], Callable[..., T]]:
Expand Down
22 changes: 16 additions & 6 deletions photoshop_mcp_server/resources/document_resources.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Document-related MCP resources."""

from photoshop_mcp_server.ps_adapter.application import PhotoshopApp
from photoshop_mcp_server.ps_adapter.utils import to_float
from photoshop_mcp_server.ps_adapter.utils import UNCONVERTIBLE, try_float


def register(mcp):
Expand Down Expand Up @@ -39,15 +39,25 @@ def get_document_info() -> dict:
if not doc:
return {"error": "No active document"}

return {
# `doc.width` / `doc.height` are either a UnitValue (with `.value`)
# or a plain float depending on the Photoshop build. When neither can be
# read the dimensions are reported as 0.0 and flagged as unavailable, so
# a client can tell a genuine zero apart from an unreadable measurement.
width = try_float(getattr(doc, "width", None))
height = try_float(getattr(doc, "height", None))
dimensions_unavailable = width is UNCONVERTIBLE or height is UNCONVERTIBLE

info = {
"name": doc.name,
# `doc.width` / `doc.height` are either a UnitValue (with `.value`)
# or a plain float depending on the Photoshop build.
"width": to_float(getattr(doc, "width", None)),
"height": to_float(getattr(doc, "height", None)),
"width": 0.0 if width is UNCONVERTIBLE else width,
"height": 0.0 if height is UNCONVERTIBLE else height,
"resolution": doc.resolution,
"layers_count": len(doc.artLayers),
}
if dimensions_unavailable:
info["dimensions_unavailable"] = True

return info

@mcp.resource("photoshop://document/layers")
def get_layers() -> dict:
Expand Down
22 changes: 16 additions & 6 deletions photoshop_mcp_server/tools/document_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from photoshop_mcp_server.logging_config import get_logger
from photoshop_mcp_server.ps_adapter.application import PhotoshopApp
from photoshop_mcp_server.ps_adapter.utils import to_float
from photoshop_mcp_server.ps_adapter.utils import UNCONVERTIBLE, to_float, try_float
from photoshop_mcp_server.registry import register_tool

logger = get_logger(__name__)
Expand Down Expand Up @@ -159,14 +159,24 @@ def open_document(file_path: str) -> dict:
ps_app = PhotoshopApp()
try:
doc = ps_app.open_document(file_path)
return {
# `doc.width` / `doc.height` are either a UnitValue (with `.value`)
# or a plain float depending on the Photoshop build. When neither can
# be read the dimensions are reported as 0.0 and flagged as
# unavailable, so a client can tell a genuine zero apart from an
# unreadable measurement.
width = try_float(getattr(doc, "width", None))
height = try_float(getattr(doc, "height", None))

result = {
"success": True,
"document_name": doc.name,
# `doc.width` / `doc.height` are either a UnitValue (with `.value`)
# or a plain float depending on the Photoshop build.
"width": to_float(getattr(doc, "width", None)),
"height": to_float(getattr(doc, "height", None)),
"width": 0.0 if width is UNCONVERTIBLE else width,
"height": 0.0 if height is UNCONVERTIBLE else height,
}
if width is UNCONVERTIBLE or height is UNCONVERTIBLE:
result["dimensions_unavailable"] = True

return result
except Exception as e:
return {"success": False, "error": str(e)}

Expand Down
152 changes: 151 additions & 1 deletion tests/unit/test_dimension_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import pytest

from photoshop_mcp_server.ps_adapter.utils import to_float
from photoshop_mcp_server.ps_adapter.utils import UNCONVERTIBLE, to_float, try_float


class FakeUnitValue:
Expand Down Expand Up @@ -94,6 +94,55 @@ def test_unit_value_with_unconvertible_value_returns_default(self):
assert to_float(FakeUnitValue("not-a-number"), default=300.0) == 300.0


class TestTryFloat:
"""Test suite for the ``try_float`` helper."""

@pytest.mark.parametrize(
("value", "expected"),
[
(1920.5, 1920.5),
(1080, 1080.0),
("1920.5", 1920.5),
],
ids=["float", "int", "numeric_string"],
)
def test_convertible_values(self, value, expected):
"""Convertible values are returned as floats."""
result = try_float(value)
assert result == expected
assert isinstance(result, float)

def test_unit_value_object(self):
"""A UnitValue-like object is unwrapped via ``.value``."""
assert try_float(FakeUnitValue(1920.5)) == 1920.5

@pytest.mark.parametrize(
("value",),
[
(None,),
(object(),),
],
ids=["none", "opaque_object"],
)
def test_unconvertible_returns_sentinel(self, value):
"""An unreadable measurement returns the sentinel instead of 0.0."""
assert try_float(value) is UNCONVERTIBLE

def test_unit_value_with_unconvertible_value_returns_sentinel(self):
"""A UnitValue whose ``.value`` is not numeric also yields the sentinel."""
assert try_float(FakeUnitValue("not-a-number")) is UNCONVERTIBLE

def test_zero_is_distinguishable_from_unconvertible(self):
"""A real zero measurement is a float, not the sentinel.

This is the whole point of the sentinel: ``to_float`` collapses both a
genuine ``0.0`` and an unreadable measurement into ``0.0``, so a caller
cannot tell them apart.
"""
assert try_float(0) == 0.0
assert try_float(0) is not UNCONVERTIBLE


class TestOpenDocument:
"""Test suite for the ``open_document`` tool."""

Expand Down Expand Up @@ -128,6 +177,8 @@ def test_returns_real_dimensions(self, open_document_tool, as_unit_value):
assert result["document_name"] == "test.psd"
assert result["width"] == 1920.0
assert result["height"] == 1080.0
# The signal is only added when the dimensions cannot be read.
assert "dimensions_unavailable" not in result

def test_missing_dimensions_do_not_raise(self, open_document_tool):
"""A document without width/height still returns a successful result."""
Expand All @@ -144,6 +195,55 @@ def test_missing_dimensions_do_not_raise(self, open_document_tool):
assert result["width"] == 0.0
assert result["height"] == 0.0

@pytest.mark.parametrize(
("width", "height"),
[
(object(), 1080),
(1920, object()),
(object(), object()),
],
ids=["width_only", "height_only", "both"],
)
def test_unreadable_dimensions_set_unavailable_flag(
self, open_document_tool, width, height
):
"""An unreadable measurement sets ``dimensions_unavailable``.

Width and height are read independently, so a single unreadable one is
enough to flag the payload.
"""
doc = FakeDocument(width=width, height=height)

with patch(
"photoshop_mcp_server.tools.document_tools.PhotoshopApp"
) as mock_app_cls:
mock_app_cls.return_value.open_document.return_value = doc
result = open_document_tool("C:/tmp/test.psd")

assert result["success"] is True
assert result["dimensions_unavailable"] is True
assert result["width"] == (1920.0 if width == 1920 else 0.0)
assert result["height"] == (1080.0 if height == 1080 else 0.0)

def test_unreadable_unit_value_dimensions_set_unavailable_flag(
self, open_document_tool
):
"""A UnitValue whose ``.value`` is not numeric is also unavailable."""
doc = FakeDocument(as_unit_value=True)
doc.width = FakeUnitValue("not-a-number")
doc.height = FakeUnitValue("not-a-number")

with patch(
"photoshop_mcp_server.tools.document_tools.PhotoshopApp"
) as mock_app_cls:
mock_app_cls.return_value.open_document.return_value = doc
result = open_document_tool("C:/tmp/test.psd")

assert result["success"] is True
assert result["dimensions_unavailable"] is True
assert result["width"] == 0.0
assert result["height"] == 0.0


class TestCreateDocument:
"""Test suite for the ``create_document`` tool.
Expand Down Expand Up @@ -254,6 +354,56 @@ def test_returns_real_dimensions(self, document_info_resource, as_unit_value):
assert result["height"] == 1080.0
assert result["resolution"] == 72
assert result["layers_count"] == 0
# The signal is only added when the dimensions cannot be read.
assert "dimensions_unavailable" not in result

@pytest.mark.parametrize(
("width", "height"),
[
(object(), 1080),
(1920, object()),
(object(), object()),
],
ids=["width_only", "height_only", "both"],
)
def test_unreadable_dimensions_set_unavailable_flag(
self, document_info_resource, width, height
):
"""An unreadable measurement sets ``dimensions_unavailable``.

The existing fields stay in place, so consumers that ignore unknown keys
keep seeing the same payload shape.
"""
doc = FakeDocument(width=width, height=height)

with patch(
"photoshop_mcp_server.resources.document_resources.PhotoshopApp"
) as mock_app_cls:
mock_app_cls.return_value.get_active_document.return_value = doc
result = document_info_resource()

assert result["dimensions_unavailable"] is True
assert result["name"] == "test.psd"
assert result["resolution"] == 72
assert result["layers_count"] == 0
assert result["width"] == (1920.0 if width == 1920 else 0.0)
assert result["height"] == (1080.0 if height == 1080 else 0.0)

def test_zero_dimensions_are_not_flagged_unavailable(
self, document_info_resource
):
"""A genuine zero measurement must not be reported as unavailable."""
doc = FakeDocument(width=0, height=0)

with patch(
"photoshop_mcp_server.resources.document_resources.PhotoshopApp"
) as mock_app_cls:
mock_app_cls.return_value.get_active_document.return_value = doc
result = document_info_resource()

assert result["width"] == 0.0
assert result["height"] == 0.0
assert "dimensions_unavailable" not in result

def test_no_active_document_returns_error(self, document_info_resource):
"""The no-document path is unchanged."""
Expand Down
Loading