├── nox_actions ├── __init__.py ├── utils.py ├── codetest.py ├── lint.py └── release.py ├── requirements-dev.txt ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_server.py │ └── test_fastmcp_initialization.py └── integration │ ├── __init__.py │ └── test_photoshop_integration.py ├── photoshop_mcp_server ├── resources │ ├── __init__.py │ ├── document_resources.py │ └── registry.py ├── ps_adapter │ ├── __init__.py │ ├── utils.py │ ├── application.py │ └── action_manager.py ├── tools │ ├── __init__.py │ ├── registry.py │ ├── session_tools.py │ ├── document_tools.py │ └── layer_tools.py ├── __init__.py ├── app.py ├── server.py ├── decorators.py └── registry.py ├── assets └── ps-mcp.gif ├── .hound.yml ├── renovate.json ├── codecov.yml ├── .isort.cfg ├── .pylintrc ├── .coveragerc ├── .gitignore ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── bumpversion.yml │ ├── issue-translator.yml │ └── python-publish.yml ├── .flake8 ├── LICENSE ├── CHANGELOG.md ├── noxfile.py ├── examples ├── session_info.py └── hello_world.py ├── pyproject.toml ├── README_zh.md └── README.md /nox_actions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | poetry 2 | nox 3 | pytest 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for photoshop-mcp-server.""" 2 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for photoshop-mcp-server.""" 2 | -------------------------------------------------------------------------------- /photoshop_mcp_server/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP resources module.""" 2 | -------------------------------------------------------------------------------- /photoshop_mcp_server/ps_adapter/__init__.py: -------------------------------------------------------------------------------- 1 | """Photoshop adapter module.""" 2 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for photoshop-mcp-server.""" 2 | -------------------------------------------------------------------------------- /assets/ps-mcp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loonghao/photoshop-python-api-mcp-server/HEAD/assets/ps-mcp.gif -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | python: 2 | enabled: true 3 | 4 | flake8: 5 | enabled: true 6 | config_file: .flake8 7 | 8 | fail_on_violations: true 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | 5 | github_checks: 6 | annotations: false 7 | 8 | ignore: 9 | - "noxfile.py" 10 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | force_grid_wrap=0 6 | use_parentheses=True 7 | ensure_newline_before_comments=True 8 | line_length=120 9 | -------------------------------------------------------------------------------- /photoshop_mcp_server/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP tools module.""" 2 | 3 | from photoshop_mcp_server.tools import document_tools, layer_tools, session_tools 4 | 5 | __all__ = ["document_tools", "layer_tools", "session_tools"] 6 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | # Generated Pylint configuration file that disables default output tables. 2 | 3 | [MESSAGES CONTROL] 4 | disable=RP0001,RP0002,RP0003,RP0101,RP0401,RP0402,RP0701,RP0801,C0103,R0903 5 | 6 | [REPORTS] 7 | output-format=text 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | tests/* 14 | -------------------------------------------------------------------------------- /photoshop_mcp_server/__init__.py: -------------------------------------------------------------------------------- 1 | """Photoshop MCP Server package.""" 2 | 3 | # Import local modules 4 | from photoshop_mcp_server.app import __version__ 5 | 6 | # Import tools and resources for easier access 7 | from photoshop_mcp_server.ps_adapter.application import PhotoshopApp 8 | 9 | __all__ = [ 10 | "PhotoshopApp", 11 | "__version__", 12 | ] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | *.py[cod] 3 | 4 | # PyCharm project files 5 | .idea/ 6 | 7 | # Vim / Notepad++ temp files 8 | *~ 9 | 10 | # Coverage output 11 | .coverage 12 | 13 | # Documentation build folders. 14 | docs/_* 15 | docs/src/* 16 | target/* 17 | /venv/ 18 | /run_pycharm.bat 19 | /.nox/ 20 | /build/ 21 | /coverage.xml 22 | /.zip/ 23 | __pycache__/ 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.10 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.3.0 6 | hooks: 7 | - id: no-commit-to-branch # prevent direct commits to main branch 8 | - id: check-yaml 9 | args: ["--unsafe"] 10 | - id: check-toml 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | -------------------------------------------------------------------------------- /nox_actions/utils.py: -------------------------------------------------------------------------------- 1 | # Import built-in modules 2 | from pathlib import Path 3 | 4 | 5 | PACKAGE_NAME = "" 6 | THIS_ROOT = Path(__file__).parent.parent 7 | PROJECT_ROOT = THIS_ROOT.parent 8 | 9 | 10 | def _assemble_env_paths(*paths): 11 | """Assemble environment paths separated by a semicolon. 12 | 13 | Args: 14 | *paths: Paths to be assembled. 15 | 16 | Returns: 17 | str: Assembled paths separated by a semicolon. 18 | 19 | """ 20 | return ";".join(paths) 21 | -------------------------------------------------------------------------------- /photoshop_mcp_server/app.py: -------------------------------------------------------------------------------- 1 | """Application configuration for Photoshop MCP Server.""" 2 | 3 | # Import third-party modules 4 | import importlib.metadata 5 | 6 | # Constants 7 | APP_NAME = "photoshop_mcp_server" 8 | APP_DESCRIPTION = "MCP Server for Photoshop integration using photoshop-python-api" 9 | 10 | # Get version from package metadata 11 | try: 12 | __version__ = importlib.metadata.version("photoshop-mcp-server") 13 | except importlib.metadata.PackageNotFoundError: 14 | __version__ = "0.1.10" # Default version if package is not installed 15 | -------------------------------------------------------------------------------- /nox_actions/codetest.py: -------------------------------------------------------------------------------- 1 | # Import built-in modules 2 | import os 3 | 4 | # Import third-party modules 5 | import nox 6 | from nox_actions.utils import PACKAGE_NAME 7 | from nox_actions.utils import THIS_ROOT 8 | 9 | 10 | def pytest(session: nox.Session) -> None: 11 | session.install(".") 12 | session.install("pytest", "pytest_cov", "pytest_mock") 13 | test_root = os.path.join(THIS_ROOT, "tests") 14 | session.run( 15 | "pytest", 16 | f"--cov={PACKAGE_NAME}", 17 | "--cov-report=xml:coverage.xml", 18 | f"--rootdir={test_root}", 19 | env={"PYTHONPATH": THIS_ROOT.as_posix()}, 20 | ) 21 | -------------------------------------------------------------------------------- /nox_actions/lint.py: -------------------------------------------------------------------------------- 1 | # Import third-party modules 2 | import nox 3 | from nox_actions.utils import PACKAGE_NAME 4 | 5 | 6 | def lint(session: nox.Session) -> None: 7 | session.install("isort", "ruff") 8 | session.run("isort", "--check-only", PACKAGE_NAME) 9 | session.run("ruff", "check") 10 | 11 | 12 | def lint_fix(session: nox.Session) -> None: 13 | session.install("isort", "ruff", "pre-commit", "autoflake") 14 | session.run("ruff", "check", "--fix") 15 | session.run("isort", ".") 16 | session.run("pre-commit", "run", "--all-files") 17 | session.run("autoflake", "--in-place", "--remove-all-unused-imports", "--remove-unused-variables") 18 | -------------------------------------------------------------------------------- /.github/workflows/bumpversion.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | bump-version: 10 | if: "!startsWith(github.event.head_commit.message, 'bump:')" 11 | runs-on: ubuntu-latest 12 | name: "Bump version and create changelog with commitizen" 13 | steps: 14 | - name: Check out 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | with: 17 | fetch-depth: 0 18 | token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}' 19 | - name: Create bump and changelog 20 | uses: commitizen-tools/commitizen-action@master 21 | with: 22 | github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 23 | branch: main 24 | -------------------------------------------------------------------------------- /.github/workflows/issue-translator.yml: -------------------------------------------------------------------------------- 1 | name: 'issue-translator' 2 | on: 3 | issue_comment: 4 | types: [created] 5 | issues: 6 | types: [opened] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: usthe/issues-translate-action@v2.7 13 | with: 14 | IS_MODIFY_TITLE: false 15 | # not require, default false, . Decide whether to modify the issue title 16 | # if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot. 17 | CUSTOM_BOT_NOTE: Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿 18 | # not require. Customize the translation robot prefix message. 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = BLK100 3 | 4 | # flake8-quotes: 5 | # Use double quotes as our default to comply with black, we like it and 6 | # don't want to use single quotes anymore. 7 | # We would love to configure this via our pyproject.toml but flake8-3.8 does 8 | # not support it yet. 9 | inline-quotes = double 10 | multiline-quotes = double 11 | docstring-quotes = double 12 | avoid-escape = True 13 | 14 | # flake8-docstrings 15 | # Use the Google Python Styleguide Docstring format. 16 | docstring-convention=google 17 | 18 | exclude = 19 | # No need to traverse our git directory 20 | .git, 21 | # There's no value in checking cache directories 22 | __pycache__, 23 | # The conf file is mostly autogenerated, ignore it 24 | docs/source/conf.py, 25 | # The old directory contains Flake8 2.0 26 | old, 27 | # This contains our built documentation 28 | build, 29 | # This contains builds of flake8 that we don't want to check 30 | dist, 31 | venv, 32 | docs 33 | 34 | max-line-length = 120 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.1.10 (2025-10-17) 2 | 3 | ### Fix 4 | 5 | - correct mock paths in FastMCP initialization tests 6 | - remove unsupported description and version parameters from FastMCP initialization 7 | 8 | ## v0.1.9 (2025-06-30) 9 | 10 | ### Fix 11 | 12 | - **deps**: update dependency mcp to v1.10.1 13 | 14 | ## v0.1.8 (2025-06-18) 15 | 16 | ### Fix 17 | 18 | - **deps**: update dependency mcp to v1.9.4 19 | 20 | ## v0.1.7 (2025-06-07) 21 | 22 | ### Fix 23 | 24 | - **deps**: update dependency mcp to v1.9.3 25 | 26 | ## v0.1.6 (2025-05-26) 27 | 28 | ### Fix 29 | 30 | - **deps**: update dependency mcp to v1.9.1 31 | 32 | ## v0.1.5 (2025-05-07) 33 | 34 | ### Fix 35 | 36 | - **deps**: update dependency mcp to v1.7.1 37 | 38 | ## v0.1.4 (2025-05-07) 39 | 40 | ### Fix 41 | 42 | - **deps**: update dependency photoshop-python-api to v0.24.1 43 | 44 | ## v0.1.3 (2025-05-02) 45 | 46 | ### Fix 47 | 48 | - **deps**: update dependency mcp to v1.7.0 49 | 50 | ## v0.1.2 (2025-04-11) 51 | 52 | ### Fix 53 | 54 | - standardize executable name to photoshop-mcp-server 55 | 56 | ## v0.1.1 (2025-04-11) 57 | 58 | ### Fix 59 | 60 | - update PyPI publish workflow to use Linux runner 61 | 62 | ## v0.1.0 (2025-04-11) 63 | 64 | ### Feat 65 | 66 | - implement dynamic tool registration for MCP server 67 | 68 | ### Fix 69 | 70 | - use PhotoshopApp instance in Action Manager 71 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # Import built-in modules 2 | import platform 3 | 4 | # Import third-party modules 5 | import nox 6 | 7 | # Define package name 8 | PACKAGE_NAME = "photoshop_mcp_server" 9 | 10 | 11 | @nox.session 12 | def lint(session): 13 | """Run linting checks.""" 14 | session.install("isort", "ruff", "black") 15 | session.run("isort", "--check-only", PACKAGE_NAME) 16 | session.run("black", "--check", PACKAGE_NAME) 17 | session.run("ruff", "check", PACKAGE_NAME) 18 | 19 | 20 | @nox.session(name="lint-fix") 21 | def lint_fix(session): 22 | """Fix linting issues.""" 23 | session.install("isort", "ruff", "black", "pre-commit") 24 | session.run("ruff", "check", "--fix", PACKAGE_NAME) 25 | session.run("isort", PACKAGE_NAME) 26 | session.run("black", PACKAGE_NAME) 27 | session.run("pre-commit", "run", "--all-files") 28 | 29 | 30 | @nox.session 31 | def pytest(session): 32 | """Run the test suite.""" 33 | session.install("-e", ".") 34 | session.install("pytest", "pytest-cov", "pytest-mock") 35 | session.run( 36 | "pytest", 37 | f"--cov={PACKAGE_NAME}", 38 | "--cov-report=xml:coverage.xml", 39 | "--cov-report=term", 40 | *session.posargs, 41 | ) 42 | 43 | 44 | @nox.session 45 | def test_photoshop(session): 46 | """Run tests that require Photoshop (Windows only).""" 47 | if platform.system() != "Windows": 48 | session.skip("Photoshop tests only run on Windows") 49 | 50 | session.install("-e", ".") 51 | session.install("pytest", "pytest-cov") 52 | session.run( 53 | "pytest", 54 | "tests/integration", 55 | f"--cov={PACKAGE_NAME}", 56 | "--cov-report=term", 57 | *session.posargs, 58 | ) 59 | 60 | 61 | @nox.session 62 | def build(session): 63 | """Build the package.""" 64 | session.install("poetry") 65 | session.run("poetry", "build") 66 | -------------------------------------------------------------------------------- /tests/integration/test_photoshop_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for Photoshop integration. 2 | 3 | These tests require Photoshop to be installed and will be skipped if it's not available. 4 | """ 5 | 6 | import os 7 | import sys 8 | import pytest 9 | import platform 10 | 11 | # Skip all tests if not on Windows 12 | pytestmark = pytest.mark.skipif( 13 | platform.system() != "Windows", reason="Photoshop integration tests only run on Windows" 14 | ) 15 | 16 | 17 | def is_photoshop_available(): 18 | """Check if Photoshop is available.""" 19 | try: 20 | import photoshop.api as ps 21 | 22 | ps.Application() 23 | return True 24 | except Exception: 25 | return False 26 | 27 | 28 | # Skip tests if Photoshop is not available 29 | pytestmark = pytest.mark.skipif(not is_photoshop_available(), reason="Photoshop is not available") 30 | 31 | 32 | @pytest.fixture 33 | def photoshop_app(): 34 | """Fixture to provide a Photoshop application instance.""" 35 | try: 36 | import photoshop.api as ps 37 | 38 | app = ps.Application() 39 | yield app 40 | except Exception as e: 41 | pytest.skip(f"Failed to initialize Photoshop: {e}") 42 | 43 | 44 | def test_photoshop_version(photoshop_app): 45 | """Test getting Photoshop version.""" 46 | assert photoshop_app.version is not None 47 | assert isinstance(photoshop_app.version, str) 48 | 49 | 50 | def test_create_document(photoshop_app): 51 | """Test creating a document.""" 52 | # Skip if we can't create documents 53 | if not hasattr(photoshop_app, "documents"): 54 | pytest.skip("Cannot access documents in Photoshop") 55 | 56 | try: 57 | import photoshop.api as ps 58 | 59 | doc = photoshop_app.documents.add(width=500, height=500, name="Test Document") 60 | assert doc is not None 61 | assert doc.name == "Test Document" 62 | 63 | # Clean up 64 | doc.close(ps.SaveOptions.DoNotSaveChanges) 65 | except Exception as e: 66 | pytest.skip(f"Failed to create document: {e}") 67 | -------------------------------------------------------------------------------- /nox_actions/release.py: -------------------------------------------------------------------------------- 1 | # Import built-in modules 2 | import argparse 3 | import os 4 | import shutil 5 | import zipfile 6 | 7 | # Import third-party modules 8 | import nox 9 | from nox_actions.utils import PACKAGE_NAME 10 | from nox_actions.utils import THIS_ROOT 11 | 12 | 13 | @nox.session(name="build-exe", reuse_venv=True) 14 | def build_exe(session: nox.Session) -> None: 15 | parser = argparse.ArgumentParser(prog="nox -s build-exe --release") 16 | parser.add_argument("--release", action="store_true") 17 | parser.add_argument("--version", default="0.5.0", help="Version to use for the zip file") 18 | parser.add_argument("--test", action="store_true") 19 | args = parser.parse_args(session.posargs) 20 | build_root = THIS_ROOT / "build" 21 | session.install("pyoxidizer") 22 | session.run("pyoxidizer", "build", "install", "--path", THIS_ROOT, "--release") 23 | for platform_name in os.listdir(build_root): 24 | platform_dir = build_root / platform_name / "release" / "install" 25 | print(os.listdir(platform_dir)) 26 | print(f"build {platform_name} -> {platform_dir}") 27 | 28 | if args.test: 29 | print("run tests") 30 | vexcle_exe = shutil.which("vexcle", path=platform_dir) 31 | assert os.path.exists(vexcle_exe) 32 | 33 | if args.release: 34 | temp_dir = os.path.join(THIS_ROOT, ".zip") 35 | version = str(args.version) 36 | print(f"make zip to current version: {version}") 37 | os.makedirs(temp_dir, exist_ok=True) 38 | zip_file = os.path.join(temp_dir, f"{PACKAGE_NAME}-{version}-{platform_name}.zip") 39 | with zipfile.ZipFile(zip_file, "w") as zip_obj: 40 | for root, _, files in os.walk(platform_dir): 41 | for file in files: 42 | zip_obj.write( 43 | os.path.join(root, file), 44 | os.path.relpath(os.path.join(root, file), os.path.join(platform_dir, ".")), 45 | ) 46 | print(f"Saving to {zip_file}") 47 | -------------------------------------------------------------------------------- /examples/session_info.py: -------------------------------------------------------------------------------- 1 | """Session Info example for Photoshop MCP Server. 2 | 3 | This example demonstrates how to use the MCP client to: 4 | 1. Get information about the current Photoshop session 5 | 2. Get detailed information about the active document 6 | 3. Get information about the current selection 7 | """ 8 | 9 | import asyncio 10 | import json 11 | from mcp import ClientSession, StdioServerParameters 12 | from mcp.client.stdio import stdio_client 13 | 14 | 15 | async def main(): 16 | """Run the Session Info example.""" 17 | # Create server parameters for stdio connection 18 | server_params = StdioServerParameters( 19 | command="python", # Executable 20 | args=["-m", "photoshop_mcp_server.server"], # Module to run 21 | ) 22 | 23 | print("Starting Photoshop MCP client...") 24 | 25 | async with stdio_client(server_params) as (read, write): 26 | async with ClientSession(read, write) as session: 27 | # Initialize the connection 28 | await session.initialize() 29 | 30 | print("Connected to Photoshop MCP Server") 31 | 32 | # Get session info 33 | print("\n1. Getting session info...") 34 | result = await session.call_tool("get_session_info") 35 | print(f"Session info:\n{json.dumps(result, indent=2)}") 36 | 37 | # Check if there's an active document 38 | if result.get("has_active_document", False): 39 | # Get active document info 40 | print("\n2. Getting active document info...") 41 | doc_info = await session.call_tool("get_active_document_info") 42 | print(f"Active document info:\n{json.dumps(doc_info, indent=2)}") 43 | 44 | # Get selection info 45 | print("\n3. Getting selection info...") 46 | selection_info = await session.call_tool("get_selection_info") 47 | print(f"Selection info:\n{json.dumps(selection_info, indent=2)}") 48 | else: 49 | print("\nNo active document. Please open a document in Photoshop and run this example again.") 50 | 51 | print("\nExample completed successfully!") 52 | 53 | 54 | if __name__ == "__main__": 55 | asyncio.run(main()) 56 | -------------------------------------------------------------------------------- /photoshop_mcp_server/resources/document_resources.py: -------------------------------------------------------------------------------- 1 | """Document-related MCP resources.""" 2 | 3 | from photoshop_mcp_server.ps_adapter.application import PhotoshopApp 4 | 5 | 6 | def register(mcp): 7 | """Register document-related resources. 8 | 9 | Args: 10 | mcp: The MCP server instance. 11 | 12 | """ 13 | 14 | @mcp.resource("photoshop://info") 15 | def get_photoshop_info() -> dict: 16 | """Get information about the Photoshop application. 17 | 18 | Returns: 19 | dict: Information about Photoshop. 20 | 21 | """ 22 | ps_app = PhotoshopApp() 23 | return { 24 | "version": ps_app.get_version(), 25 | "has_active_document": ps_app.get_active_document() is not None, 26 | } 27 | 28 | @mcp.resource("photoshop://document/info") 29 | def get_document_info() -> dict: 30 | """Get information about the active document. 31 | 32 | Returns: 33 | dict: Information about the active document or an error message. 34 | 35 | """ 36 | ps_app = PhotoshopApp() 37 | doc = ps_app.get_active_document() 38 | if not doc: 39 | return {"error": "No active document"} 40 | 41 | return { 42 | "name": doc.name, 43 | "width": doc.width.value, 44 | "height": doc.height.value, 45 | "resolution": doc.resolution, 46 | "layers_count": len(doc.artLayers), 47 | } 48 | 49 | @mcp.resource("photoshop://document/layers") 50 | def get_layers() -> dict: 51 | """Get information about the layers in the active document. 52 | 53 | Returns: 54 | dict: Information about layers or an error message. 55 | 56 | """ 57 | ps_app = PhotoshopApp() 58 | doc = ps_app.get_active_document() 59 | if not doc: 60 | return {"error": "No active document"} 61 | 62 | layers = [] 63 | for i, layer in enumerate(doc.artLayers): 64 | layers.append( 65 | { 66 | "index": i, 67 | "name": layer.name, 68 | "visible": layer.visible, 69 | "kind": str(layer.kind), 70 | } 71 | ) 72 | 73 | return {"layers": layers} 74 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | """Hello World example for Photoshop MCP Server. 2 | 3 | This example demonstrates how to use the MCP client to: 4 | 1. Create a new document 5 | 2. Add a text layer with "Hello, World!" text 6 | 3. Save the document as a JPEG file 7 | """ 8 | 9 | import asyncio 10 | import os 11 | from mcp import ClientSession, StdioServerParameters 12 | from mcp.client.stdio import stdio_client 13 | 14 | 15 | async def main(): 16 | """Run the Hello World example.""" 17 | # Path to save the output image 18 | output_path = os.path.join(os.path.dirname(__file__), "hello_world.jpg") 19 | 20 | # Create server parameters for stdio connection 21 | server_params = StdioServerParameters( 22 | command="python", # Executable 23 | args=["-m", "photoshop_mcp_server.server"], # Module to run 24 | ) 25 | 26 | print("Starting Photoshop MCP client...") 27 | 28 | async with stdio_client(server_params) as (read, write): 29 | async with ClientSession(read, write) as session: 30 | # Initialize the connection 31 | await session.initialize() 32 | 33 | print("Connected to Photoshop MCP Server") 34 | 35 | # Create a new document 36 | print("Creating a new document...") 37 | result = await session.call_tool( 38 | "create_document", arguments={"width": 800, "height": 600, "name": "Hello World Document"} 39 | ) 40 | print(f"Document created: {result}") 41 | 42 | # Create a text layer 43 | print("Adding 'Hello, World!' text layer...") 44 | result = await session.call_tool( 45 | "create_text_layer", 46 | arguments={ 47 | "text": "Hello, World!", 48 | "x": 250, 49 | "y": 300, 50 | "size": 72, 51 | "color_r": 0, 52 | "color_g": 255, 53 | "color_b": 0, 54 | }, 55 | ) 56 | print(f"Text layer created: {result}") 57 | 58 | # Save the document 59 | print(f"Saving document to {output_path}...") 60 | result = await session.call_tool("save_document", arguments={"file_path": output_path, "format": "jpg"}) 61 | print(f"Document saved: {result}") 62 | 63 | print("Example completed successfully!") 64 | 65 | 66 | if __name__ == "__main__": 67 | asyncio.run(main()) 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "photoshop-mcp-server" 7 | version = "0.1.10" 8 | description = "MCP Server for Photoshop integration using photoshop-python-api" 9 | authors = ["longhao "] 10 | readme = "README.md" 11 | license = "MIT" 12 | repository = "https://github.com/loonghao/photoshop-python-api-mcp-server" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: Microsoft :: Windows", 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = ">=3.10,<3.15" 21 | mcp = "^1.6.0" 22 | photoshop-python-api = "^0.24.0" 23 | loguru = "^0.7.3" 24 | tenacity = "^9.0.0" 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | pytest = "^8.0.0" 28 | pytest-cov = "^6.0.0" 29 | pytest-mock = "^3.11.1" 30 | 31 | [tool.poetry.scripts] 32 | photoshop-mcp-server = "photoshop_mcp_server.server:main" 33 | 34 | [tool.poetry.urls] 35 | Homepage = "https://github.com/loonghao/photoshop-python-api-mcp-server" 36 | Issues = "https://github.com/loonghao/photoshop-python-api-mcp-server/issues" 37 | 38 | [tool.mcp] 39 | name = "Photoshop" 40 | description = "Control Adobe Photoshop using MCP" 41 | version = "0.1.10" 42 | icon = "https://raw.githubusercontent.com/loonghao/photoshop-python-api-mcp-server/main/assets/photoshop-icon.png" 43 | authors = ["Hal "] 44 | repository = "https://github.com/loonghao/photoshop-python-api-mcp-server" 45 | entrypoint = "photoshop_mcp_server.server:create_server" 46 | 47 | [tool.pytest.ini_options] 48 | addopts = "" 49 | testpaths = ["tests"] 50 | python_files = ["test_*.py"] 51 | python_functions = ["test_*"] 52 | 53 | [tool.coverage.run] 54 | source = ["photoshop_mcp_server"] 55 | branch = true 56 | 57 | [tool.commitizen] 58 | name = "cz_conventional_commits" 59 | version = "0.1.10" 60 | tag_format = "v$version" 61 | version_files = [ 62 | "pyproject.toml:version", 63 | "photoshop_mcp_server/app.py:__version__", 64 | ] 65 | 66 | [tool.ruff] 67 | line-length = 120 68 | target-version = "py310" 69 | src = ["photoshop_mcp_server", "tests"] 70 | 71 | [tool.ruff.lint] 72 | select = [ 73 | "E", # pycodestyle 74 | "F", # pyflakes 75 | "D", # pydocstyle 76 | "UP", # pyupgrade 77 | "RUF", # ruff-specific rules 78 | ] 79 | ignore = ["D203", "D213", "ARG001", "D107", "D105", "D102", "F811", "I001"] 80 | 81 | [tool.ruff.lint.per-file-ignores] 82 | "__init__.py" = ["F401"] 83 | "tests/*.py" = ["ARG001", "F401", "F811", "D107", "D105", "D102", "E501", "I001"] 84 | 85 | [tool.ruff.format] 86 | quote-style = "double" 87 | indent-style = "space" 88 | skip-magic-trailing-comma = false 89 | line-ending = "auto" 90 | 91 | [tool.nox] 92 | sessions = ["lint", "lint_fix", "pytest", "build"] 93 | python = ["3.10", "3.11", "3.12", "3.13", "3.14"] 94 | reuse_venv = true 95 | 96 | [tool.nox.session.lint] 97 | deps = ["ruff", "mypy"] 98 | commands = [ 99 | "mypy --install-types --non-interactive", 100 | "ruff check .", 101 | "ruff format --check .", 102 | "mypy photoshop_mcp_server --strict" 103 | ] 104 | 105 | [tool.nox.session.lint_fix] 106 | deps = ["ruff", "mypy"] 107 | commands = [ 108 | "ruff check --fix .", 109 | "ruff format ." 110 | ] 111 | 112 | [tool.nox.session.pytest] 113 | deps = ["pytest", "pytest-cov"] 114 | commands = [ 115 | "pytest tests/ --cov=photoshop_mcp_server --cov-report=xml:coverage.xml --cov-report=term-missing" 116 | ] 117 | 118 | [tool.nox.session.build] 119 | deps = ["poetry"] 120 | commands = [ 121 | "poetry build" 122 | ] 123 | -------------------------------------------------------------------------------- /photoshop_mcp_server/ps_adapter/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for Photoshop adapter.""" 2 | 3 | import time 4 | from collections.abc import Callable 5 | from functools import wraps 6 | from typing import Any, TypeVar, cast 7 | 8 | from loguru import logger 9 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed 10 | 11 | # Type variable for generic function 12 | T = TypeVar("T") 13 | 14 | 15 | def with_retry( 16 | max_attempts: int = 3, wait_seconds: float = 2.0 17 | ) -> Callable[[Callable[..., T]], Callable[..., T]]: 18 | """Retry a function if it fails. 19 | 20 | This is particularly useful when Photoshop is starting up and not immediately responsive. 21 | 22 | Args: 23 | max_attempts: Maximum number of retry attempts. 24 | wait_seconds: Time to wait between retries in seconds. 25 | 26 | Returns: 27 | Decorated function with retry logic. 28 | 29 | """ 30 | 31 | def decorator(func: Callable[..., T]) -> Callable[..., T]: 32 | @wraps(func) 33 | def wrapper(*args: Any, **kwargs: Any) -> T: 34 | attempts = 0 35 | last_exception = None 36 | 37 | while attempts < max_attempts: 38 | try: 39 | return func(*args, **kwargs) 40 | except Exception as e: 41 | attempts += 1 42 | last_exception = e 43 | 44 | if attempts < max_attempts: 45 | logger.warning( 46 | f"Error executing {func.__name__}: {e}. " 47 | f"Retrying in {wait_seconds} seconds... " 48 | f"(Attempt {attempts}/{max_attempts})" 49 | ) 50 | time.sleep(wait_seconds) 51 | else: 52 | logger.error( 53 | f"Failed to execute {func.__name__} after {max_attempts} attempts: {e}" 54 | ) 55 | 56 | # If we get here, all attempts failed 57 | if last_exception: 58 | raise last_exception 59 | 60 | # This should never happen, but to satisfy the type checker 61 | raise RuntimeError( 62 | f"Failed to execute {func.__name__} after {max_attempts} attempts" 63 | ) 64 | 65 | return cast(Callable[..., T], wrapper) 66 | 67 | return decorator 68 | 69 | 70 | def with_tenacity_retry( 71 | max_attempts: int = 5, 72 | wait_seconds: float = 2.0, 73 | exception_types: tuple = (Exception,), 74 | ) -> Callable[[Callable[..., T]], Callable[..., T]]: 75 | """Retry a function using tenacity. 76 | 77 | This provides more advanced retry capabilities for Photoshop operations. 78 | 79 | Args: 80 | max_attempts: Maximum number of retry attempts. 81 | wait_seconds: Time to wait between retries in seconds. 82 | exception_types: Tuple of exception types to retry on. 83 | 84 | Returns: 85 | Decorated function with retry logic. 86 | 87 | """ 88 | 89 | def decorator(func: Callable[..., T]) -> Callable[..., T]: 90 | @retry( 91 | stop=stop_after_attempt(max_attempts), 92 | wait=wait_fixed(wait_seconds), 93 | retry=retry_if_exception_type(exception_types), 94 | before_sleep=lambda retry_state: logger.warning( 95 | f"Error executing {func.__name__}: {retry_state.outcome.exception()}. " 96 | f"Retrying in {wait_seconds} seconds... " 97 | f"(Attempt {retry_state.attempt_number}/{max_attempts})" 98 | ), 99 | ) 100 | @wraps(func) 101 | def wrapper(*args: Any, **kwargs: Any) -> T: 102 | return func(*args, **kwargs) 103 | 104 | return cast(Callable[..., T], wrapper) 105 | 106 | return decorator 107 | -------------------------------------------------------------------------------- /tests/unit/test_server.py: -------------------------------------------------------------------------------- 1 | """Tests for the server module.""" 2 | 3 | import pytest 4 | from unittest.mock import patch, MagicMock, call 5 | 6 | from photoshop_mcp_server.server import create_server 7 | from mcp.server.fastmcp import FastMCP 8 | 9 | 10 | @pytest.fixture 11 | def mock_photoshop(): 12 | """Mock the photoshop-python-api.""" 13 | with patch("photoshop_mcp_server.ps_adapter.application.ps") as mock_ps: 14 | # Mock Application 15 | mock_app = MagicMock() 16 | mock_ps.Application.return_value = mock_app 17 | 18 | # Mock version 19 | mock_app.version = "2023" 20 | 21 | yield mock_ps 22 | 23 | 24 | class TestCreateServer: 25 | """Test suite for create_server function.""" 26 | 27 | def test_create_server_default_parameters(self): 28 | """Test creating an MCP server with default parameters.""" 29 | server = create_server() 30 | assert server is not None 31 | assert isinstance(server, FastMCP) 32 | assert server.name == "Photoshop" 33 | 34 | def test_create_server_with_custom_name(self): 35 | """Test creating an MCP server with a custom name.""" 36 | server = create_server(name="Custom Photoshop") 37 | assert server is not None 38 | assert isinstance(server, FastMCP) 39 | assert server.name == "Custom Photoshop" 40 | 41 | def test_create_server_with_custom_version(self): 42 | """Test creating an MCP server with a custom version. 43 | 44 | Note: FastMCP doesn't expose version as a public attribute in the latest SDK, 45 | but we verify the server is created successfully without errors. 46 | """ 47 | server = create_server(version="1.2.3") 48 | assert server is not None 49 | assert isinstance(server, FastMCP) 50 | 51 | def test_create_server_fastmcp_initialization(self): 52 | """Test that FastMCP is initialized with correct parameters. 53 | 54 | This test ensures that FastMCP is called with only the 'name' parameter, 55 | avoiding the 'unexpected keyword argument' error that occurred with 56 | older versions that tried to pass 'description' and 'version'. 57 | """ 58 | with patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp: 59 | mock_instance = MagicMock() 60 | mock_fastmcp.return_value = mock_instance 61 | 62 | # Call create_server with various parameters 63 | create_server(name="TestServer", description="Test Description", version="0.1.0") 64 | 65 | # Verify FastMCP was called with only the name parameter 66 | mock_fastmcp.assert_called_once_with(name="TestServer") 67 | 68 | def test_create_server_with_config(self): 69 | """Test creating an MCP server with additional configuration.""" 70 | config = {"env_vars": {"TEST_VAR": "test_value"}} 71 | 72 | with patch.dict("os.environ", {}, clear=False): 73 | server = create_server(config=config) 74 | assert server is not None 75 | assert isinstance(server, FastMCP) 76 | # Verify environment variable was set 77 | import os 78 | 79 | assert os.environ.get("TEST_VAR") == "test_value" 80 | 81 | def test_create_server_registers_resources(self): 82 | """Test that create_server registers resources.""" 83 | with patch("photoshop_mcp_server.server.register_all_resources") as mock_register_resources: 84 | mock_register_resources.return_value = {"test_module": ["resource1"]} 85 | 86 | server = create_server() 87 | 88 | assert server is not None 89 | mock_register_resources.assert_called_once() 90 | 91 | def test_create_server_registers_tools(self): 92 | """Test that create_server registers tools.""" 93 | with patch("photoshop_mcp_server.server.register_all_tools") as mock_register_tools: 94 | mock_register_tools.return_value = {"test_module": ["tool1"]} 95 | 96 | server = create_server() 97 | 98 | assert server is not None 99 | mock_register_tools.assert_called_once() 100 | -------------------------------------------------------------------------------- /photoshop_mcp_server/tools/registry.py: -------------------------------------------------------------------------------- 1 | """Tool registry for Photoshop MCP Server. 2 | 3 | This module provides functions for dynamically registering tools with the MCP server. 4 | """ 5 | 6 | import importlib 7 | import inspect 8 | import pkgutil 9 | from collections.abc import Callable 10 | 11 | from loguru import logger 12 | from mcp.server.fastmcp import FastMCP 13 | 14 | # Set of modules that have been registered 15 | _registered_modules: set[str] = set() 16 | 17 | 18 | def register_tools_from_module(mcp_server: FastMCP, module_name: str) -> list[str]: 19 | """Register all tools from a module. 20 | 21 | Args: 22 | mcp_server: The MCP server instance. 23 | module_name: The name of the module to register tools from. 24 | 25 | Returns: 26 | List of registered tool names. 27 | 28 | """ 29 | if module_name in _registered_modules: 30 | logger.debug(f"Module {module_name} already registered") 31 | return [] 32 | 33 | try: 34 | module = importlib.import_module(module_name) 35 | registered_tools = [] 36 | 37 | # Check if the module has a register function 38 | if hasattr(module, "register") and callable(module.register): 39 | logger.info( 40 | f"Registering tools from {module_name} using register() function" 41 | ) 42 | module.register(mcp_server) 43 | _registered_modules.add(module_name) 44 | # We can't know what tools were registered, so return empty list 45 | return registered_tools 46 | 47 | # Otherwise, look for functions with @mcp.tool() decorator 48 | for name, obj in inspect.getmembers(module): 49 | if inspect.isfunction(obj) and hasattr(obj, "__mcp_tool__"): 50 | logger.info(f"Found MCP tool: {name}") 51 | registered_tools.append(name) 52 | 53 | _registered_modules.add(module_name) 54 | return registered_tools 55 | 56 | except ImportError as e: 57 | logger.error(f"Failed to import module {module_name}: {e}") 58 | return [] 59 | 60 | 61 | def register_all_tools( 62 | mcp_server: FastMCP, package_name: str = "photoshop_mcp_server.tools" 63 | ) -> dict[str, list[str]]: 64 | """Register all tools from all modules in a package. 65 | 66 | Args: 67 | mcp_server: The MCP server instance. 68 | package_name: The name of the package to register tools from. 69 | 70 | Returns: 71 | Dictionary mapping module names to lists of registered tool names. 72 | 73 | """ 74 | registered_tools = {} 75 | 76 | try: 77 | package = importlib.import_module(package_name) 78 | 79 | # Skip __init__.py and registry.py 80 | skip_modules = {"__init__", "registry"} 81 | 82 | for _, module_name, is_pkg in pkgutil.iter_modules( 83 | package.__path__, package.__name__ + "." 84 | ): 85 | if module_name.split(".")[-1] in skip_modules: 86 | continue 87 | 88 | tools = register_tools_from_module(mcp_server, module_name) 89 | if tools: 90 | registered_tools[module_name] = tools 91 | 92 | # If it's a package, register all modules in it 93 | if is_pkg: 94 | sub_tools = register_all_tools(mcp_server, module_name) 95 | registered_tools.update(sub_tools) 96 | 97 | except ImportError as e: 98 | logger.error(f"Failed to import package {package_name}: {e}") 99 | 100 | return registered_tools 101 | 102 | 103 | def register_tool(mcp_server: FastMCP, func: Callable, name: str | None = None) -> str: 104 | """Register a function as an MCP tool. 105 | 106 | Args: 107 | mcp_server: The MCP server instance. 108 | func: The function to register. 109 | name: Optional name for the tool. If not provided, the function name is used. 110 | 111 | Returns: 112 | The name of the registered tool. 113 | 114 | """ 115 | tool_name = name or func.__name__ 116 | mcp_server.tool(name=tool_name)(func) 117 | logger.info(f"Registered tool: {tool_name}") 118 | return tool_name 119 | -------------------------------------------------------------------------------- /photoshop_mcp_server/resources/registry.py: -------------------------------------------------------------------------------- 1 | """Resource registry for Photoshop MCP Server. 2 | 3 | This module provides functions for dynamically registering resources with the MCP server. 4 | """ 5 | 6 | import importlib 7 | import inspect 8 | import pkgutil 9 | from collections.abc import Callable 10 | 11 | from loguru import logger 12 | from mcp.server.fastmcp import FastMCP 13 | 14 | # Set of modules that have been registered 15 | _registered_modules: set[str] = set() 16 | 17 | 18 | def register_resources_from_module(mcp_server: FastMCP, module_name: str) -> list[str]: 19 | """Register all resources from a module. 20 | 21 | Args: 22 | mcp_server: The MCP server instance. 23 | module_name: The name of the module to register resources from. 24 | 25 | Returns: 26 | List of registered resource names. 27 | 28 | """ 29 | if module_name in _registered_modules: 30 | logger.debug(f"Module {module_name} already registered") 31 | return [] 32 | 33 | try: 34 | module = importlib.import_module(module_name) 35 | registered_resources = [] 36 | 37 | # Check if the module has a register function 38 | if hasattr(module, "register") and callable(module.register): 39 | logger.info( 40 | f"Registering resources from {module_name} using register() function" 41 | ) 42 | module.register(mcp_server) 43 | _registered_modules.add(module_name) 44 | # We can't know what resources were registered, so return empty list 45 | return registered_resources 46 | 47 | # Otherwise, look for functions with @mcp.resource() decorator 48 | for name, obj in inspect.getmembers(module): 49 | if inspect.isfunction(obj) and hasattr(obj, "__mcp_resource__"): 50 | logger.info(f"Found MCP resource: {name}") 51 | registered_resources.append(name) 52 | 53 | _registered_modules.add(module_name) 54 | return registered_resources 55 | 56 | except ImportError as e: 57 | logger.error(f"Failed to import module {module_name}: {e}") 58 | return [] 59 | 60 | 61 | def register_all_resources( 62 | mcp_server: FastMCP, package_name: str = "photoshop_mcp_server.resources" 63 | ) -> dict[str, list[str]]: 64 | """Register all resources from all modules in a package. 65 | 66 | Args: 67 | mcp_server: The MCP server instance. 68 | package_name: The name of the package to register resources from. 69 | 70 | Returns: 71 | Dictionary mapping module names to lists of registered resource names. 72 | 73 | """ 74 | registered_resources = {} 75 | 76 | try: 77 | package = importlib.import_module(package_name) 78 | 79 | # Skip __init__.py and registry.py 80 | skip_modules = {"__init__", "registry"} 81 | 82 | for _, module_name, is_pkg in pkgutil.iter_modules( 83 | package.__path__, package.__name__ + "." 84 | ): 85 | if module_name.split(".")[-1] in skip_modules: 86 | continue 87 | 88 | resources = register_resources_from_module(mcp_server, module_name) 89 | if resources: 90 | registered_resources[module_name] = resources 91 | 92 | # If it's a package, register all modules in it 93 | if is_pkg: 94 | sub_resources = register_all_resources(mcp_server, module_name) 95 | registered_resources.update(sub_resources) 96 | 97 | except ImportError as e: 98 | logger.error(f"Failed to import package {package_name}: {e}") 99 | 100 | return registered_resources 101 | 102 | 103 | def register_resource(mcp_server: FastMCP, func: Callable, path: str) -> str: 104 | """Register a function as an MCP resource. 105 | 106 | Args: 107 | mcp_server: The MCP server instance. 108 | func: The function to register. 109 | path: The resource path. 110 | 111 | Returns: 112 | The path of the registered resource. 113 | 114 | """ 115 | mcp_server.resource(path)(func) 116 | logger.info(f"Registered resource: {path}") 117 | return path 118 | -------------------------------------------------------------------------------- /photoshop_mcp_server/server.py: -------------------------------------------------------------------------------- 1 | """Photoshop MCP Server main module.""" 2 | 3 | import logging 4 | import os 5 | import sys 6 | from typing import Any 7 | 8 | from mcp.server.fastmcp import FastMCP 9 | 10 | # Import version 11 | from photoshop_mcp_server.app import __version__ 12 | 13 | # Import registry 14 | from photoshop_mcp_server.registry import register_all_resources, register_all_tools 15 | 16 | # Configure logging 17 | logging.basicConfig( 18 | level=logging.INFO, 19 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 20 | handlers=[logging.StreamHandler(sys.stdout)], 21 | ) 22 | logger = logging.getLogger("photoshop-mcp-server") 23 | 24 | 25 | def create_server( 26 | name: str = "Photoshop", 27 | description: str = "Control Adobe Photoshop using MCP", 28 | version: str | None = None, 29 | config: dict[str, Any] | None = None, 30 | ) -> FastMCP: 31 | """Create and configure the MCP server. 32 | 33 | Args: 34 | name: The name of the MCP server. 35 | description: A description of the server's functionality. 36 | version: The server version (defaults to package version). 37 | config: Additional configuration options. 38 | 39 | Returns: 40 | FastMCP: The configured MCP server. 41 | 42 | """ 43 | # Use provided version or fall back to package version 44 | server_version = version or __version__ 45 | 46 | # Create a new MCP server with the provided configuration 47 | from mcp.server.fastmcp import FastMCP 48 | 49 | server_mcp = FastMCP(name=name) 50 | 51 | # Register all resources dynamically 52 | logger.info("Registering resources...") 53 | registered_resources = register_all_resources(server_mcp) 54 | logger.info( 55 | f"Registered resources from modules: {list(registered_resources.keys())}" 56 | ) 57 | 58 | # Register all tools dynamically 59 | logger.info("Registering tools...") 60 | registered_tools = register_all_tools(server_mcp) 61 | logger.info(f"Registered tools from modules: {list(registered_tools.keys())}") 62 | 63 | # Apply additional configuration if provided 64 | if config: 65 | logger.info(f"Applying additional configuration: {config}") 66 | # Example: Set environment variables 67 | if "env_vars" in config: 68 | for key, value in config["env_vars"].items(): 69 | os.environ[key] = str(value) 70 | 71 | logger.info(f"Server '{name}' v{server_version} configured successfully") 72 | return server_mcp 73 | 74 | 75 | def main(): 76 | """Run the main entry point for the server. 77 | 78 | This function parses command-line arguments and starts the MCP server. 79 | It can be invoked directly or through the 'ps-mcp' entry point. 80 | 81 | Command-line arguments: 82 | --name: Server name (default: "Photoshop") 83 | --description: Server description 84 | --version: Server version (overrides package version) 85 | --debug: Enable debug logging 86 | """ 87 | import argparse 88 | 89 | # Parse command-line arguments 90 | parser = argparse.ArgumentParser(description="Photoshop MCP Server") 91 | parser.add_argument("--name", default="Photoshop", help="Server name") 92 | parser.add_argument( 93 | "--description", 94 | default="Control Adobe Photoshop using MCP", 95 | help="Server description", 96 | ) 97 | parser.add_argument("--version", help="Server version (overrides package version)") 98 | parser.add_argument("--debug", action="store_true", help="Enable debug logging") 99 | args = parser.parse_args() 100 | 101 | # Configure logging level 102 | if args.debug: 103 | logging.getLogger().setLevel(logging.DEBUG) 104 | logger.setLevel(logging.DEBUG) 105 | logger.debug("Debug logging enabled") 106 | 107 | logger.info(f"Starting Photoshop MCP Server v{args.version or __version__}...") 108 | 109 | try: 110 | # Configure and run the server with command-line arguments 111 | server_mcp = create_server( 112 | name=args.name, description=args.description, version=args.version 113 | ) 114 | server_mcp.run() 115 | except Exception as e: 116 | logger.error(f"Error starting server: {e}") 117 | sys.exit(1) 118 | 119 | 120 | if __name__ == "__main__": 121 | main() 122 | -------------------------------------------------------------------------------- /photoshop_mcp_server/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators for MCP tools.""" 2 | 3 | import functools 4 | import inspect 5 | import sys 6 | import traceback 7 | from collections.abc import Callable 8 | from typing import Any, TypeVar 9 | 10 | F = TypeVar("F", bound=Callable[..., Any]) 11 | 12 | 13 | def debug_tool(func: F) -> F: 14 | """Add detailed error information to MCP tool functions. 15 | 16 | This decorator wraps MCP tool functions to catch exceptions and provide 17 | detailed error information in the response, including: 18 | - Exception type 19 | - Exception message 20 | - Stack trace 21 | - Function arguments 22 | 23 | Args: 24 | func: The MCP tool function to decorate 25 | 26 | Returns: 27 | The decorated function 28 | 29 | """ 30 | 31 | @functools.wraps(func) 32 | def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]: 33 | try: 34 | return func(*args, **kwargs) 35 | except Exception as e: 36 | # Get the exception info 37 | exc_type, exc_value, exc_traceback = sys.exc_info() 38 | 39 | # Format the traceback 40 | tb_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) 41 | tb_text = "".join(tb_lines) 42 | 43 | # Print to console for server-side debugging 44 | print(f"ERROR in {func.__name__}:\n{tb_text}") 45 | 46 | # Get the function arguments 47 | arg_spec = inspect.getfullargspec(func) 48 | arg_names = arg_spec.args 49 | 50 | # Create a dictionary of argument names and values 51 | # Skip 'self' if it's a method 52 | start_idx = 1 if arg_names and arg_names[0] == "self" else 0 53 | arg_dict = {} 54 | for i, arg_name in enumerate(arg_names[start_idx:], start_idx): 55 | if i < len(args): 56 | arg_dict[arg_name] = repr(args[i]) 57 | 58 | # Add keyword arguments 59 | for key, value in kwargs.items(): 60 | arg_dict[key] = repr(value) 61 | 62 | # Format arguments for display 63 | args_str = ", ".join(f"{k}={v}" for k, v in arg_dict.items()) 64 | 65 | # Create a user-friendly error message 66 | user_error = f"Error in {func.__name__}: {e!s}\nArguments: {args_str}\n\nTraceback:\n{tb_text}" 67 | 68 | # Create detailed error response 69 | error_response = { 70 | "success": False, 71 | "error": str(e), # Original short error 72 | "detailed_error": user_error, # Detailed error for display 73 | "error_type": exc_type.__name__, 74 | "traceback": tb_text, 75 | "function": func.__name__, 76 | "arguments": arg_dict, 77 | "module": func.__module__, 78 | } 79 | 80 | return error_response 81 | 82 | return wrapper # type: ignore 83 | 84 | 85 | def log_tool_call(func: F) -> F: 86 | """Log MCP tool function calls. 87 | 88 | This decorator logs the function name and arguments when called, 89 | and the result when the function returns. 90 | 91 | Args: 92 | func: The MCP tool function to decorate 93 | 94 | Returns: 95 | The decorated function 96 | 97 | """ 98 | 99 | @functools.wraps(func) 100 | def wrapper(*args: Any, **kwargs: Any) -> Any: 101 | # Get the function arguments 102 | arg_spec = inspect.getfullargspec(func) 103 | arg_names = arg_spec.args 104 | 105 | # Create a dictionary of argument names and values 106 | # Skip 'self' if it's a method 107 | start_idx = 1 if arg_names and arg_names[0] == "self" else 0 108 | arg_dict = {} 109 | for i, arg_name in enumerate(arg_names[start_idx:], start_idx): 110 | if i < len(args): 111 | arg_dict[arg_name] = repr(args[i]) 112 | 113 | # Add keyword arguments 114 | for key, value in kwargs.items(): 115 | arg_dict[key] = repr(value) 116 | 117 | # Log the function call 118 | print( 119 | f"TOOL CALL: {func.__name__}({', '.join(f'{k}={v}' for k, v in arg_dict.items())})" 120 | ) 121 | 122 | # Call the function 123 | result = func(*args, **kwargs) 124 | 125 | # Log the result 126 | print(f"TOOL RESULT: {func.__name__} -> {result}") 127 | 128 | return result 129 | 130 | return wrapper # type: ignore 131 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # Photoshop MCP 服务器 2 | 3 | [![PyPI Version](https://img.shields.io/pypi/v/photoshop-mcp-server.svg)](https://pypi.org/project/photoshop-mcp-server/) 4 | [![PyPI Downloads](https://img.shields.io/pypi/dm/photoshop-mcp-server.svg)](https://pypi.org/project/photoshop-mcp-server/) 5 | [![Build Status](https://github.com/loonghao/photoshop-python-api-mcp-server/actions/workflows/python-publish.yml/badge.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server/actions/workflows/python-publish.yml) 6 | [![License](https://img.shields.io/github/license/loonghao/photoshop-python-api-mcp-server.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server/blob/main/LICENSE) 7 | [![Python Version](https://img.shields.io/pypi/pyversions/photoshop-mcp-server.svg)](https://pypi.org/project/photoshop-mcp-server/) 8 | [![Platform](https://img.shields.io/badge/platform-windows-lightgrey.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server) 9 | [![GitHub stars](https://img.shields.io/github/stars/loonghao/photoshop-python-api-mcp-server.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server/stargazers) 10 | [![GitHub issues](https://img.shields.io/github/issues/loonghao/photoshop-python-api-mcp-server.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server/issues) 11 | [![GitHub last commit](https://img.shields.io/github/last-commit/loonghao/photoshop-python-api-mcp-server.svg)](https://github.com/loonghao/photoshop-python-api-mcp-server/commits/main) 12 | 13 | > **⚠️ 仅支持 Windows**: 此服务器仅适用于 Windows 操作系统,因为它依赖于 Windows 特有的 COM 接口。 14 | 15 | 一个使用 photoshop-python-api 的 Photoshop 集成的模型上下文协议 (MCP) 服务器。 16 | 17 | [English](README.md) | 简体中文 18 | 19 | ## 概述 20 | 21 | 本项目提供了模型上下文协议 (MCP) 和 Adobe Photoshop 之间的桥梁,允许 AI 助手和其他 MCP 客户端以编程方式控制 Photoshop。 22 | 23 | ![Photoshop MCP 服务器演示](assets/ps-mcp.gif) 24 | 25 | ### 它能做什么? 26 | 27 | 通过这个 MCP 服务器,AI 助手可以: 28 | 29 | - 创建、打开和保存 Photoshop 文档 30 | - 创建和操作图层(文本、纯色等) 31 | - 获取有关 Photoshop 会话和文档的信息 32 | - 对图像应用效果和调整 33 | - 以及更多功能! 34 | 35 | ## 要求 36 | 37 | ### 系统要求 38 | 39 | - **🔴 仅支持 Windows 系统**:此服务器**仅**适用于 Windows 操作系统 40 | - 服务器依赖于 Windows 特有的 COM 接口与 Photoshop 通信 41 | - macOS 和 Linux **不受支持**,**无法**运行此软件 42 | 43 | ### 软件要求 44 | 45 | - **Adobe Photoshop**:必须在本地安装(已测试 CC2017 至 2024 版本) 46 | - **Python**:版本 3.10 或更高 47 | 48 | ## 安装 49 | 50 | > **注意**:请记住,此软件包仅适用于 Windows 系统。 51 | 52 | ```bash 53 | # 使用 pip 安装 54 | pip install photoshop-mcp-server 55 | 56 | # 或使用 uv 57 | uv install photoshop-mcp-server 58 | ``` 59 | 60 | ## MCP 主机配置 61 | 62 | 此服务器设计为与各种 MCP 主机一起工作。`PS_VERSION` 环境变量用于指定要连接的 Photoshop 版本(例如,"2024"、"2023"、"2022" 等)。 63 | 64 | 推荐使用 `uvx` 作为命令来配置服务器,这是官方标准格式。 65 | 66 | ### 标准配置(推荐) 67 | 68 | 将以下内容添加到您的 MCP 主机配置中(适用于 Claude Desktop、Windsurf、Cline 和其他 MCP 主机): 69 | 70 | ```json 71 | { 72 | "mcpServers": { 73 | "photoshop": { 74 | "command": "uvx", 75 | "args": ["--python", "3.10", "photoshop-mcp-server"], 76 | "env": { 77 | "PS_VERSION": "2024" 78 | } 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ### 配置选项 85 | 86 | - **PS_VERSION**:指定要连接的 Photoshop 版本(例如,"2024"、"2023"、"2022" 等) 87 | - **command**:使用 `uvx` 作为标准方法 88 | - **args**:使用 `["photoshop-mcp-server"]` 运行 Photoshop MCP 服务器 89 | - 要显式指定 Python 版本,请使用 `["--python", "3.10", "photoshop-mcp-server"]`(支持从 3.10 到 3.14 的任何版本) 90 | 91 | ## 主要功能 92 | 93 | ### 可用资源 94 | 95 | - `photoshop://info` - 获取 Photoshop 应用程序信息 96 | - `photoshop://document/info` - 获取活动文档信息 97 | - `photoshop://document/layers` - 获取活动文档中的图层 98 | 99 | ### 可用工具 100 | 101 | 服务器提供了各种控制 Photoshop 的工具: 102 | 103 | - **文档工具**:创建、打开和保存文档 104 | - **图层工具**:创建文本图层、纯色图层等 105 | - **会话工具**:获取有关 Photoshop 会话、活动文档、选择的信息 106 | 107 | ## AI 助手提示词示例 108 | 109 | 一旦在 MCP 主机中配置好,您就可以在 AI 助手对话中使用 Photoshop MCP 服务器。以下是一些帮助您入门的示例提示词: 110 | 111 | ### 基础示例 112 | 113 | ```text 114 | 用户:能否创建一个新的 Photoshop 文档并添加一个带有"Hello World"的文本图层? 115 | 116 | AI 助手:我将为您创建一个新文档并添加文本图层。 117 | 118 | [AI 使用 Photoshop MCP 服务器: 119 | 1. 使用 `create_document` 工具创建新文档 120 | 2. 使用 `create_text_layer` 工具添加文本为"Hello World"的文本图层] 121 | 122 | 我已创建了一个新的 Photoshop 文档并添加了一个带有"Hello World"的文本图层。 123 | ``` 124 | 125 | ### 更高级的示例 126 | 127 | ```text 128 | 用户:打开我最新的 PSD 文件并告诉我它有多少个图层。 129 | 130 | AI 助手:我将检查您最新的 PSD 文件并计算图层数量。 131 | 132 | [AI 使用 Photoshop MCP 服务器: 133 | 1. 获取有关打开文档的信息 134 | 2. 如果没有打开文档,它可以浏览并打开 PSD 文件 135 | 3. 检索并分析图层结构] 136 | 137 | 您的文档有 12 个图层,包括 3 个组文件夹和 2 个调整图层。 138 | ``` 139 | 140 | ```text 141 | 用户:创建一个社交媒体帖子,带有渐变背景和粗体字"夏季促销"文本。 142 | 143 | AI 助手:我将为您创建该社交媒体帖子。 144 | 145 | [AI 使用 Photoshop MCP 服务器: 146 | 1. 创建一个适合社交媒体的新文档 147 | 2. 创建一个带有夏季颜色的渐变填充图层 148 | 3. 添加一个带有"夏季促销"文本的粗体字图层 149 | 4. 适当地定位和样式化文本] 150 | 151 | 我已为您创建了带有渐变背景和粗体"夏季促销"文本的社交媒体帖子。 152 | ``` 153 | 154 | ## 许可证 155 | 156 | MIT 157 | 158 | ## 致谢 159 | 160 | - [photoshop-python-api](https://github.com/loonghao/photoshop-python-api) - Photoshop 的 Python API 161 | - [Model Context Protocol](https://github.com/modelcontextprotocol/python-sdk) - MCP Python SDK 162 | -------------------------------------------------------------------------------- /photoshop_mcp_server/tools/session_tools.py: -------------------------------------------------------------------------------- 1 | """Session-related MCP tools for Photoshop.""" 2 | 3 | from typing import Any 4 | 5 | from photoshop_mcp_server.ps_adapter.action_manager import ActionManager 6 | from photoshop_mcp_server.registry import register_tool 7 | 8 | 9 | def register(mcp): 10 | """Register session-related tools. 11 | 12 | Args: 13 | mcp: The MCP server instance. 14 | 15 | Returns: 16 | list: List of registered tool names. 17 | 18 | """ 19 | registered_tools = [] 20 | 21 | def get_session_info() -> dict[str, Any]: 22 | """Get information about the current Photoshop session. 23 | 24 | Returns: 25 | dict: Information about the current Photoshop session. 26 | 27 | """ 28 | try: 29 | print("Getting Photoshop session information using Action Manager") 30 | 31 | # Use Action Manager to get session info 32 | session_info = ActionManager.get_session_info() 33 | print( 34 | f"Session info retrieved successfully: {session_info.get('success', False)}" 35 | ) 36 | 37 | return session_info 38 | 39 | except Exception as e: 40 | print(f"Error getting Photoshop session info: {e}") 41 | import traceback 42 | 43 | tb_text = traceback.format_exc() 44 | traceback.print_exc() 45 | 46 | # Create a detailed error message 47 | detailed_error = f"Error getting Photoshop session information:\nError: {e!s}\n\nTraceback:\n{tb_text}" 48 | 49 | return { 50 | "success": False, 51 | "is_running": False, 52 | "error": str(e), 53 | "detailed_error": detailed_error, 54 | } 55 | 56 | # Register the get_session_info function with a specific name 57 | tool_name = register_tool(mcp, get_session_info, "get_session_info") 58 | registered_tools.append(tool_name) 59 | 60 | def get_active_document_info() -> dict[str, Any]: 61 | """Get detailed information about the active document. 62 | 63 | Returns: 64 | dict: Detailed information about the active document or an error message. 65 | 66 | """ 67 | try: 68 | print("Getting active document information using Action Manager") 69 | 70 | # Use Action Manager to get document info 71 | doc_info = ActionManager.get_active_document_info() 72 | print( 73 | f"Document info retrieved successfully: {doc_info.get('success', False)}" 74 | ) 75 | 76 | return doc_info 77 | 78 | except Exception as e: 79 | print(f"Error getting active document info: {e}") 80 | import traceback 81 | 82 | tb_text = traceback.format_exc() 83 | traceback.print_exc() 84 | 85 | # Create a detailed error message 86 | detailed_error = f"Error getting active document information:\nError: {e!s}\n\nTraceback:\n{tb_text}" 87 | 88 | return {"success": False, "error": str(e), "detailed_error": detailed_error} 89 | 90 | # Register the get_active_document_info function with a specific name 91 | tool_name = register_tool(mcp, get_active_document_info, "get_active_document_info") 92 | registered_tools.append(tool_name) 93 | 94 | def get_selection_info() -> dict[str, Any]: 95 | """Get information about the current selection in the active document. 96 | 97 | Returns: 98 | dict: Information about the current selection or an error message. 99 | 100 | """ 101 | try: 102 | print("Getting selection information using Action Manager") 103 | 104 | # Use Action Manager to get selection info 105 | selection_info = ActionManager.get_selection_info() 106 | print( 107 | f"Selection info retrieved successfully: {selection_info.get('success', False)}" 108 | ) 109 | 110 | return selection_info 111 | 112 | except Exception as e: 113 | print(f"Error getting selection info: {e}") 114 | import traceback 115 | 116 | tb_text = traceback.format_exc() 117 | traceback.print_exc() 118 | 119 | # Create a detailed error message 120 | detailed_error = f"Error getting selection information:\nError: {e!s}\n\nTraceback:\n{tb_text}" 121 | 122 | return { 123 | "success": False, 124 | "has_selection": False, 125 | "error": str(e), 126 | "detailed_error": detailed_error, 127 | } 128 | 129 | # Register the get_selection_info function with a specific name 130 | tool_name = register_tool(mcp, get_selection_info, "get_selection_info") 131 | registered_tools.append(tool_name) 132 | 133 | return registered_tools 134 | -------------------------------------------------------------------------------- /tests/unit/test_fastmcp_initialization.py: -------------------------------------------------------------------------------- 1 | """Tests for FastMCP initialization to prevent regression of parameter errors. 2 | 3 | This test module specifically validates that FastMCP is initialized with the correct 4 | parameters according to the latest MCP SDK API. This prevents regressions of issues 5 | like 'FastMCP.__init__() got an unexpected keyword argument' errors. 6 | """ 7 | 8 | import pytest 9 | from unittest.mock import patch, MagicMock 10 | from mcp.server.fastmcp import FastMCP 11 | 12 | from photoshop_mcp_server.server import create_server 13 | 14 | 15 | class TestFastMCPInitialization: 16 | """Test suite for FastMCP initialization parameters.""" 17 | 18 | def test_fastmcp_accepts_name_parameter(self): 19 | """Test that FastMCP can be initialized with name parameter.""" 20 | server = FastMCP(name="TestServer") 21 | assert server is not None 22 | assert server.name == "TestServer" 23 | 24 | def test_fastmcp_does_not_accept_description_parameter(self): 25 | """Test that FastMCP rejects description parameter. 26 | 27 | This test ensures that the old API (which accepted description) 28 | is no longer supported, validating our fix. 29 | """ 30 | with pytest.raises(TypeError, match="unexpected keyword argument"): 31 | FastMCP(name="TestServer", description="Test Description") 32 | 33 | def test_fastmcp_does_not_accept_version_parameter(self): 34 | """Test that FastMCP rejects version parameter. 35 | 36 | This test ensures that the old API (which accepted version) 37 | is no longer supported, validating our fix. 38 | """ 39 | with pytest.raises(TypeError, match="unexpected keyword argument"): 40 | FastMCP(name="TestServer", version="1.0.0") 41 | 42 | def test_create_server_does_not_pass_description_to_fastmcp(self): 43 | """Test that create_server doesn't pass description to FastMCP. 44 | 45 | This is the critical test that validates our fix. Even though 46 | create_server accepts a description parameter, it should NOT 47 | pass it to FastMCP. 48 | """ 49 | with patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp: 50 | mock_instance = MagicMock() 51 | mock_fastmcp.return_value = mock_instance 52 | 53 | # Call with description parameter 54 | create_server(name="TestServer", description="This is a test description") 55 | 56 | # Verify FastMCP was called with only name, not description 57 | mock_fastmcp.assert_called_once_with(name="TestServer") 58 | 59 | # Ensure description was NOT passed 60 | call_kwargs = mock_fastmcp.call_args.kwargs 61 | assert "description" not in call_kwargs 62 | 63 | def test_create_server_does_not_pass_version_to_fastmcp(self): 64 | """Test that create_server doesn't pass version to FastMCP. 65 | 66 | This validates that the version parameter is handled internally 67 | but not passed to FastMCP. 68 | """ 69 | with patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp: 70 | mock_instance = MagicMock() 71 | mock_fastmcp.return_value = mock_instance 72 | 73 | # Call with version parameter 74 | create_server(name="TestServer", version="1.2.3") 75 | 76 | # Verify FastMCP was called with only name, not version 77 | mock_fastmcp.assert_called_once_with(name="TestServer") 78 | 79 | # Ensure version was NOT passed 80 | call_kwargs = mock_fastmcp.call_args.kwargs 81 | assert "version" not in call_kwargs 82 | 83 | def test_create_server_with_all_parameters(self): 84 | """Test create_server with all parameters doesn't break FastMCP init. 85 | 86 | This comprehensive test ensures that even when all parameters are 87 | provided to create_server, FastMCP is initialized correctly. 88 | """ 89 | with patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp: 90 | mock_instance = MagicMock() 91 | mock_fastmcp.return_value = mock_instance 92 | 93 | # Call with all parameters 94 | create_server( 95 | name="FullTestServer", 96 | description="Full test description", 97 | version="2.0.0", 98 | config={"env_vars": {"TEST": "value"}}, 99 | ) 100 | 101 | # Verify FastMCP was called correctly 102 | mock_fastmcp.assert_called_once_with(name="FullTestServer") 103 | 104 | # Verify only name was passed 105 | call_kwargs = mock_fastmcp.call_args.kwargs 106 | assert len(call_kwargs) == 1 107 | assert "name" in call_kwargs 108 | assert "description" not in call_kwargs 109 | assert "version" not in call_kwargs 110 | 111 | def test_fastmcp_initialization_signature(self): 112 | """Test that FastMCP.__init__ has the expected signature. 113 | 114 | This test documents the expected FastMCP API and will fail if 115 | the API changes unexpectedly. 116 | """ 117 | import inspect 118 | 119 | sig = inspect.signature(FastMCP.__init__) 120 | params = list(sig.parameters.keys()) 121 | 122 | # Should have 'self' and 'name' at minimum 123 | assert "self" in params 124 | assert "name" in params 125 | 126 | # Should NOT have these parameters (which caused the original error) 127 | assert "description" not in params or sig.parameters["description"].default is inspect.Parameter.empty 128 | assert "version" not in params or sig.parameters["version"].default is inspect.Parameter.empty 129 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | permissions: 4 | contents: write # For creating releases 5 | pull-requests: write # For commenting on PRs 6 | id-token: write # For PyPI trusted publishing 7 | 8 | on: 9 | push: 10 | tags: 11 | - 'v*' 12 | branches: [ main ] 13 | paths-ignore: 14 | - '**.md' 15 | - 'docs/**' 16 | - '.github/*.md' 17 | - '.github/ISSUE_TEMPLATE/**' 18 | - 'LICENSE*' 19 | - '.readthedocs.yml' 20 | - 'CITATION.cff' 21 | - 'CODE_OF_CONDUCT.md' 22 | - 'CONTRIBUTING.md' 23 | - '**.rst' 24 | - '.hound.yml' 25 | - '.gitignore' 26 | - '.gitmodules' 27 | - '.coveragerc' 28 | - 'codecov.yml' 29 | - '.flake8' 30 | - '.pylintrc' 31 | - 'renovate.json' 32 | release: 33 | types: [published] 34 | pull_request: 35 | branches: [ main ] 36 | paths-ignore: 37 | - '**.md' 38 | - 'docs/**' 39 | - '.github/*.md' 40 | - '.github/ISSUE_TEMPLATE/**' 41 | - 'LICENSE*' 42 | - '.readthedocs.yml' 43 | - 'CITATION.cff' 44 | - 'CODE_OF_CONDUCT.md' 45 | - 'CONTRIBUTING.md' 46 | - '**.rst' 47 | - '.hound.yml' 48 | - '.gitignore' 49 | - '.gitmodules' 50 | - '.coveragerc' 51 | - 'codecov.yml' 52 | - '.flake8' 53 | - '.pylintrc' 54 | - 'renovate.json' 55 | workflow_dispatch: 56 | inputs: 57 | fast-mode: 58 | description: 'Skip building wheels and only run tests' 59 | required: false 60 | default: false 61 | type: boolean 62 | python-version: 63 | description: 'Python version to use for testing' 64 | required: false 65 | default: '3.10' 66 | type: string 67 | os: 68 | description: 'OS to run tests on (only Windows is supported)' 69 | required: false 70 | default: 'windows-latest' 71 | type: choice 72 | options: 73 | - windows-latest 74 | 75 | jobs: 76 | # Build and test the package 77 | build-and-test: 78 | name: Build and test on ${{ matrix.os }} with Python ${{ matrix.python-version }} 79 | runs-on: ${{ matrix.os }} 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | os: [windows-latest] 84 | python-version: ['3.10', '3.11', '3.12', "3.13"] 85 | # No exclusions needed as we only support Windows 86 | steps: 87 | - uses: actions/checkout@v4 88 | with: 89 | fetch-depth: 0 90 | - name: Set up Python ${{ matrix.python-version }} 91 | uses: actions/setup-python@v5 92 | with: 93 | python-version: ${{ matrix.python-version }} 94 | cache: 'pip' 95 | cache-dependency-path: | 96 | **/pyproject.toml 97 | **/requirements*.txt 98 | - name: Install dependencies 99 | run: | 100 | python -m pip install --upgrade pip 101 | python -m pip install uv 102 | uvx poetry lock 103 | uvx poetry install 104 | - name: Lint 105 | run: | 106 | uvx nox -s lint 107 | - name: Test 108 | run: | 109 | uvx nox -s pytest 110 | - name: Build package 111 | run: | 112 | uvx poetry build 113 | - name: Upload artifacts 114 | uses: actions/upload-artifact@v4 115 | with: 116 | name: dist-${{ matrix.os }}-${{ matrix.python-version }} 117 | path: dist/ 118 | if-no-files-found: error 119 | 120 | # Prepare Release 121 | prepare-release: 122 | name: Prepare Release 123 | needs: [build-and-test] 124 | if: github.event_name == 'release' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) 125 | runs-on: windows-latest 126 | permissions: 127 | contents: write 128 | steps: 129 | - uses: actions/checkout@v4 130 | with: 131 | fetch-depth: 0 132 | - name: Download all artifacts 133 | uses: actions/download-artifact@v4 134 | with: 135 | path: dist 136 | merge-multiple: true 137 | # List all artifacts 138 | - name: List all artifacts 139 | run: Get-ChildItem -Path dist -Recurse -File | Sort-Object FullName 140 | shell: pwsh 141 | - name: Generate Release Notes 142 | id: release_notes 143 | run: | 144 | $VERSION = $env:GITHUB_REF -replace 'refs/tags/', '' 145 | "VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append 146 | "RELEASE_NOTES< **⚠️ WINDOWS ONLY**: This server only works on Windows operating systems due to its dependency on Windows-specific COM interfaces. 14 | 15 | A Model Context Protocol (MCP) server for Photoshop integration using photoshop-python-api. 16 | 17 | English | [简体中文](README_zh.md) 18 | 19 | ## Overview 20 | 21 | This project provides a bridge between the Model Context Protocol (MCP) and Adobe Photoshop, allowing AI assistants and other MCP clients to control Photoshop programmatically. 22 | 23 | ![Photoshop MCP Server Demo](assets/ps-mcp.gif) 24 | 25 | ### What Can It Do? 26 | 27 | With this MCP server, AI assistants can: 28 | 29 | - Create, open, and save Photoshop documents 30 | - Create and manipulate layers (text, solid color, etc.) 31 | - Get information about the Photoshop session and documents 32 | - Apply effects and adjustments to images 33 | - And much more! 34 | 35 | ## Requirements 36 | 37 | ### System Requirements 38 | 39 | - **🔴 WINDOWS OS ONLY**: This server ONLY works on Windows operating systems 40 | - The server relies on Windows-specific COM interfaces to communicate with Photoshop 41 | - macOS and Linux are NOT supported and CANNOT run this software 42 | 43 | ### Software Requirements 44 | 45 | - **Adobe Photoshop**: Must be installed locally (tested with versions CC2017 through 2024) 46 | - **Python**: Version 3.10 or higher 47 | 48 | ## Installation 49 | 50 | > **Note**: Remember that this package only works on Windows systems. 51 | 52 | ```bash 53 | # Install using pip 54 | pip install photoshop-mcp-server 55 | 56 | # Or using uv 57 | uv install photoshop-mcp-server 58 | ``` 59 | 60 | ## MCP Host Configuration 61 | 62 | This server is designed to work with various MCP hosts. The `PS_VERSION` environment variable is used to specify which Photoshop version to connect to (e.g., "2024", "2023", "2022", etc.). 63 | 64 | The recommended way to configure the server is using `uvx` as the command, which is the official standard format. 65 | 66 | ### Standard Configuration (Recommended) 67 | 68 | Add the following to your MCP host configuration (works with Claude Desktop, Windsurf, Cline, and other MCP hosts): 69 | 70 | ```json 71 | { 72 | "mcpServers": { 73 | "photoshop": { 74 | "command": "uvx", 75 | "args": ["--python", "3.10", "photoshop-mcp-server"], 76 | "env": { 77 | "PS_VERSION": "2024" 78 | } 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ### Configuration Options 85 | 86 | - **PS_VERSION**: Specify the Photoshop version to connect to (e.g., "2024", "2023", "2022", etc.) 87 | - **command**: Use `uvx` for the standard approach 88 | - **args**: Use `["photoshop-mcp-server"]` to run the Photoshop MCP server 89 | - To specify a Python version explicitly, use `["--python", "3.10", "photoshop-mcp-server"]` (any version from 3.10 to 3.14 is supported) 90 | 91 | ## Key Features 92 | 93 | ### Available Resources 94 | 95 | - `photoshop://info` - Get Photoshop application information 96 | - `photoshop://document/info` - Get active document information 97 | - `photoshop://document/layers` - Get layers in the active document 98 | 99 | ### Available Tools 100 | 101 | The server provides various tools for controlling Photoshop: 102 | 103 | - **Document Tools**: Create, open, and save documents 104 | - **Layer Tools**: Create text layers, solid color layers, etc. 105 | - **Session Tools**: Get information about Photoshop session, active document, selection 106 | 107 | ## Example Prompts for AI Assistants 108 | 109 | Once configured in your MCP host, you can use the Photoshop MCP server in your AI assistant conversations. Here are some example prompts to get you started: 110 | 111 | ### Basic Examples 112 | 113 | ```text 114 | User: Can you create a new Photoshop document and add a text layer with "Hello World"? 115 | 116 | AI Assistant: I'll create a new document and add the text layer for you. 117 | 118 | [The AI uses the Photoshop MCP server to: 119 | 1. Create a new document using the `create_document` tool 120 | 2. Add a text layer using the `create_text_layer` tool with the text "Hello World"] 121 | 122 | I've created a new Photoshop document and added a text layer with "Hello World". 123 | ``` 124 | 125 | ### More Advanced Examples 126 | 127 | ```text 128 | User: Open my latest PSD file and tell me how many layers it has. 129 | 130 | AI Assistant: I'll check your latest PSD file and count the layers. 131 | 132 | [The AI uses the Photoshop MCP server to: 133 | 1. Get information about open documents 134 | 2. If no document is open, it can browse and open a PSD file 135 | 3. Retrieve and analyze the layer structure] 136 | 137 | Your document has 12 layers, including 3 group folders and 2 adjustment layers. 138 | ``` 139 | 140 | ```text 141 | User: Create a social media post with a gradient background and the text "Summer Sale" in a bold font. 142 | 143 | AI Assistant: I'll create that social media post for you. 144 | 145 | [The AI uses the Photoshop MCP server to: 146 | 1. Create a new document with appropriate dimensions for social media 147 | 2. Create a gradient fill layer with summer colors 148 | 3. Add a text layer with "Summer Sale" in a bold font 149 | 4. Position and style the text appropriately] 150 | 151 | I've created your social media post with a gradient background and bold "Summer Sale" text. 152 | ``` 153 | 154 | ## License 155 | 156 | MIT 157 | 158 | ## Acknowledgements 159 | 160 | - [photoshop-python-api](https://github.com/loonghao/photoshop-python-api) - Python API for Photoshop 161 | - [Model Context Protocol](https://github.com/modelcontextprotocol/python-sdk) - MCP Python SDK 162 | -------------------------------------------------------------------------------- /photoshop_mcp_server/registry.py: -------------------------------------------------------------------------------- 1 | """Registry module for Photoshop MCP Server. 2 | 3 | This module provides functions for dynamically registering tools and resources with the MCP server. 4 | """ 5 | 6 | import importlib 7 | import inspect 8 | import pkgutil 9 | from collections.abc import Callable 10 | from typing import Literal 11 | 12 | from loguru import logger 13 | from mcp.server.fastmcp import FastMCP 14 | 15 | from photoshop_mcp_server.decorators import debug_tool, log_tool_call 16 | 17 | # Set of modules that have been registered 18 | _registered_modules: set[str] = set() 19 | 20 | 21 | def register_from_module( 22 | mcp_server: FastMCP, module_name: str, registry_type: Literal["tool", "resource"] 23 | ) -> list[str]: 24 | """Register all tools or resources from a module. 25 | 26 | Args: 27 | mcp_server: The MCP server instance. 28 | module_name: The name of the module to register from. 29 | registry_type: The type of registry ("tool" or "resource"). 30 | 31 | Returns: 32 | List of registered item names. 33 | 34 | """ 35 | registry_key = f"{registry_type}:{module_name}" 36 | if registry_key in _registered_modules: 37 | logger.debug(f"Module {module_name} already registered for {registry_type}") 38 | return [] 39 | 40 | try: 41 | module = importlib.import_module(module_name) 42 | registered_items = [] 43 | 44 | # Check if the module has a register function 45 | if hasattr(module, "register") and callable(module.register): 46 | logger.info( 47 | f"Registering {registry_type}s from {module_name} using register() function" 48 | ) 49 | module.register(mcp_server) 50 | _registered_modules.add(registry_key) 51 | # We can't know what items were registered, so return empty list 52 | return registered_items 53 | 54 | # Otherwise, look for functions with appropriate decorator 55 | attr_name = f"__mcp_{registry_type}__" 56 | for name, obj in inspect.getmembers(module): 57 | if inspect.isfunction(obj) and hasattr(obj, attr_name): 58 | logger.info(f"Found MCP {registry_type}: {name}") 59 | registered_items.append(name) 60 | 61 | _registered_modules.add(registry_key) 62 | return registered_items 63 | 64 | except ImportError as e: 65 | logger.error(f"Failed to import module {module_name}: {e}") 66 | return [] 67 | 68 | 69 | def register_all( 70 | mcp_server: FastMCP, package_name: str, registry_type: Literal["tool", "resource"] 71 | ) -> dict[str, list[str]]: 72 | """Register all tools or resources from all modules in a package. 73 | 74 | Args: 75 | mcp_server: The MCP server instance. 76 | package_name: The name of the package to register from. 77 | registry_type: The type of registry ("tool" or "resource"). 78 | 79 | Returns: 80 | Dictionary mapping module names to lists of registered item names. 81 | 82 | """ 83 | registered_items = {} 84 | 85 | try: 86 | package = importlib.import_module(package_name) 87 | 88 | # Skip __init__.py and registry.py 89 | skip_modules = {"__init__", "registry"} 90 | 91 | for _, module_name, is_pkg in pkgutil.iter_modules( 92 | package.__path__, package.__name__ + "." 93 | ): 94 | if module_name.split(".")[-1] in skip_modules: 95 | continue 96 | 97 | items = register_from_module(mcp_server, module_name, registry_type) 98 | if items: 99 | registered_items[module_name] = items 100 | 101 | # If it's a package, register all modules in it 102 | if is_pkg: 103 | sub_items = register_all(mcp_server, module_name, registry_type) 104 | registered_items.update(sub_items) 105 | 106 | except ImportError as e: 107 | logger.error(f"Failed to import package {package_name}: {e}") 108 | 109 | return registered_items 110 | 111 | 112 | def register_all_tools( 113 | mcp_server: FastMCP, package_name: str = "photoshop_mcp_server.tools" 114 | ) -> dict[str, list[str]]: 115 | """Register all tools from all modules in a package. 116 | 117 | Args: 118 | mcp_server: The MCP server instance. 119 | package_name: The name of the package to register tools from. 120 | 121 | Returns: 122 | Dictionary mapping module names to lists of registered tool names. 123 | 124 | """ 125 | return register_all(mcp_server, package_name, "tool") 126 | 127 | 128 | def register_all_resources( 129 | mcp_server: FastMCP, package_name: str = "photoshop_mcp_server.resources" 130 | ) -> dict[str, list[str]]: 131 | """Register all resources from all modules in a package. 132 | 133 | Args: 134 | mcp_server: The MCP server instance. 135 | package_name: The name of the package to register resources from. 136 | 137 | Returns: 138 | Dictionary mapping module names to lists of registered resource names. 139 | 140 | """ 141 | return register_all(mcp_server, package_name, "resource") 142 | 143 | 144 | def register_tool( 145 | mcp_server: FastMCP, 146 | func: Callable, 147 | name: str | None = None, 148 | namespace: str = "photoshop", 149 | debug: bool = True, 150 | ) -> str: 151 | """Register a function as an MCP tool. 152 | 153 | Args: 154 | mcp_server: The MCP server instance. 155 | func: The function to register. 156 | name: Optional name for the tool. If not provided, the function name is used. 157 | namespace: Namespace prefix for the tool name. Default is "photoshop". 158 | debug: Whether to wrap the function with debug_tool decorator. Default is True. 159 | 160 | Returns: 161 | The name of the registered tool. 162 | 163 | """ 164 | base_name = name or func.__name__ 165 | 166 | # Add namespace prefix if not already present 167 | if namespace and not base_name.startswith(f"{namespace}_"): 168 | tool_name = f"{namespace}_{base_name}" 169 | else: 170 | tool_name = base_name 171 | 172 | # Apply decorators 173 | if debug: 174 | # Apply debug_tool decorator to capture detailed error information 175 | decorated_func = debug_tool(func) 176 | # Apply log_tool_call decorator to log function calls and results 177 | decorated_func = log_tool_call(decorated_func) 178 | else: 179 | decorated_func = func 180 | 181 | mcp_server.tool(name=tool_name)(decorated_func) 182 | logger.info(f"Registered tool: {tool_name}") 183 | return tool_name 184 | 185 | 186 | def register_resource(mcp_server: FastMCP, func: Callable, path: str) -> str: 187 | """Register a function as an MCP resource. 188 | 189 | Args: 190 | mcp_server: The MCP server instance. 191 | func: The function to register. 192 | path: The resource path. 193 | 194 | Returns: 195 | The path of the registered resource. 196 | 197 | """ 198 | mcp_server.resource(path)(func) 199 | logger.info(f"Registered resource: {path}") 200 | return path 201 | -------------------------------------------------------------------------------- /photoshop_mcp_server/tools/document_tools.py: -------------------------------------------------------------------------------- 1 | """Document-related MCP tools.""" 2 | 3 | import photoshop.api as ps 4 | 5 | from photoshop_mcp_server.ps_adapter.application import PhotoshopApp 6 | from photoshop_mcp_server.registry import register_tool 7 | 8 | 9 | def register(mcp): 10 | """Register document-related tools. 11 | 12 | Args: 13 | mcp: The MCP server instance. 14 | 15 | Returns: 16 | list: List of registered tool names. 17 | 18 | """ 19 | registered_tools = [] 20 | 21 | def create_document( 22 | width: int = 1000, height: int = 1000, name: str = "Untitled", mode: str = "rgb" 23 | ) -> dict: 24 | """Create a new document in Photoshop. 25 | 26 | Args: 27 | width: Document width in pixels. 28 | height: Document height in pixels. 29 | name: Document name. 30 | mode: Color mode (rgb, cmyk, etc.). Defaults to "rgb". 31 | 32 | Returns: 33 | dict: Result of the operation. 34 | 35 | """ 36 | print( 37 | f"Creating document: width={width}, height={height}, name={name}, mode={mode}" 38 | ) 39 | ps_app = PhotoshopApp() 40 | try: 41 | # Validate mode parameter 42 | valid_modes = ["rgb", "cmyk", "grayscale", "gray", "bitmap", "lab"] 43 | if mode.lower() not in valid_modes: 44 | return { 45 | "success": False, 46 | "error": f"Invalid mode: {mode}. Valid modes are: {', '.join(valid_modes)}", 47 | "detailed_error": ( 48 | f"Invalid color mode: {mode}\n\n" 49 | f"Valid modes are: {', '.join(valid_modes)}\n\n" 50 | f"The mode parameter specifies the color mode of the new document. " 51 | f"It must be one of the valid modes listed above." 52 | ), 53 | } 54 | 55 | # Create document 56 | print( 57 | f"Calling ps_app.create_document with width={width}, height={height}, name={name}, mode={mode}" 58 | ) 59 | doc = ps_app.create_document( 60 | width=width, height=height, name=name, mode=mode 61 | ) 62 | 63 | if not doc: 64 | return { 65 | "success": False, 66 | "error": "Failed to create document - returned None", 67 | } 68 | 69 | # Get document properties safely 70 | try: 71 | print("Document created, getting properties") 72 | doc_name = doc.name 73 | print(f"Document name: {doc_name}") 74 | 75 | # Get width safely 76 | doc_width = width # Default fallback 77 | if hasattr(doc, "width"): 78 | width_obj = doc.width 79 | print(f"Width object type: {type(width_obj)}") 80 | if hasattr(width_obj, "value"): 81 | doc_width = width_obj.value 82 | else: 83 | try: 84 | doc_width = float(width_obj) 85 | except (TypeError, ValueError): 86 | print(f"Could not convert width to float: {width_obj}") 87 | print(f"Document width: {doc_width}") 88 | 89 | # Get height safely 90 | doc_height = height # Default fallback 91 | if hasattr(doc, "height"): 92 | height_obj = doc.height 93 | print(f"Height object type: {type(height_obj)}") 94 | if hasattr(height_obj, "value"): 95 | doc_height = height_obj.value 96 | else: 97 | try: 98 | doc_height = float(height_obj) 99 | except (TypeError, ValueError): 100 | print(f"Could not convert height to float: {height_obj}") 101 | print(f"Document height: {doc_height}") 102 | 103 | return { 104 | "success": True, 105 | "document_name": doc_name, 106 | "width": doc_width, 107 | "height": doc_height, 108 | } 109 | except Exception as prop_error: 110 | print(f"Error getting document properties: {prop_error}") 111 | import traceback 112 | 113 | traceback.print_exc() 114 | # Document was created but we couldn't get properties 115 | return { 116 | "success": True, 117 | "document_name": name, 118 | "width": width, 119 | "height": height, 120 | "warning": f"Created document but couldn't get properties: {prop_error!s}", 121 | } 122 | except Exception as e: 123 | print(f"Error creating document: {e}") 124 | import traceback 125 | 126 | tb_text = traceback.format_exc() 127 | traceback.print_exc() 128 | 129 | # Create a detailed error message 130 | detailed_error = ( 131 | f"Error creating document with parameters:\n" 132 | f" width: {width}\n" 133 | f" height: {height}\n" 134 | f" name: {name}\n" 135 | f" mode: {mode}\n\n" 136 | f"Error: {e!s}\n\n" 137 | f"Traceback:\n{tb_text}" 138 | ) 139 | 140 | return { 141 | "success": False, 142 | "error": str(e), 143 | "detailed_error": detailed_error, 144 | "parameters": { 145 | "width": width, 146 | "height": height, 147 | "name": name, 148 | "mode": mode, 149 | }, 150 | } 151 | 152 | # Register the create_document function with a specific name 153 | tool_name = register_tool(mcp, create_document, "create_document") 154 | registered_tools.append(tool_name) 155 | 156 | def open_document(file_path: str) -> dict: 157 | """Open an existing document. 158 | 159 | Args: 160 | file_path: Path to the document file. 161 | 162 | Returns: 163 | dict: Result of the operation. 164 | 165 | """ 166 | ps_app = PhotoshopApp() 167 | try: 168 | doc = ps_app.open_document(file_path) 169 | return { 170 | "success": True, 171 | "document_name": doc.name, 172 | "width": doc.width.value, 173 | "height": doc.height.value, 174 | } 175 | except Exception as e: 176 | return {"success": False, "error": str(e)} 177 | 178 | # Register the open_document function with a specific name 179 | tool_name = register_tool(mcp, open_document, "open_document") 180 | registered_tools.append(tool_name) 181 | 182 | def save_document(file_path: str, format: str = "psd") -> dict: 183 | """Save the active document. 184 | 185 | Args: 186 | file_path: Path where to save the document. 187 | format: File format (psd, jpg, png). 188 | 189 | Returns: 190 | dict: Result of the operation. 191 | 192 | """ 193 | ps_app = PhotoshopApp() 194 | doc = ps_app.get_active_document() 195 | if not doc: 196 | return {"success": False, "error": "No active document"} 197 | 198 | try: 199 | if format.lower() == "jpg" or format.lower() == "jpeg": 200 | options = ps.JPEGSaveOptions(quality=10) 201 | doc.saveAs(file_path, options, asCopy=True) 202 | elif format.lower() == "png": 203 | options = ps.PNGSaveOptions() 204 | doc.saveAs(file_path, options, asCopy=True) 205 | else: # Default to PSD 206 | options = ps.PhotoshopSaveOptions() 207 | doc.saveAs(file_path, options, asCopy=True) 208 | 209 | return {"success": True, "file_path": file_path} 210 | except Exception as e: 211 | return {"success": False, "error": str(e)} 212 | 213 | # Register the save_document function with a specific name 214 | tool_name = register_tool(mcp, save_document, "save_document") 215 | registered_tools.append(tool_name) 216 | 217 | # Return the list of registered tools 218 | return registered_tools 219 | -------------------------------------------------------------------------------- /photoshop_mcp_server/tools/layer_tools.py: -------------------------------------------------------------------------------- 1 | """Layer-related MCP tools.""" 2 | 3 | import photoshop.api as ps 4 | 5 | from photoshop_mcp_server.ps_adapter.application import PhotoshopApp 6 | from photoshop_mcp_server.registry import register_tool 7 | 8 | 9 | def register(mcp): 10 | """Register layer-related tools. 11 | 12 | Args: 13 | mcp: The MCP server instance. 14 | 15 | Returns: 16 | list: List of registered tool names. 17 | 18 | """ 19 | registered_tools = [] 20 | 21 | def create_text_layer( 22 | text: str, 23 | x: int = 100, 24 | y: int = 100, 25 | size: int = 24, 26 | color_r: int = 0, 27 | color_g: int = 0, 28 | color_b: int = 0, 29 | ) -> dict: 30 | """Create a text layer. 31 | 32 | Args: 33 | text: Text content. 34 | x: X position. 35 | y: Y position. 36 | size: Font size. 37 | color_r: Red component (0-255). 38 | color_g: Green component (0-255). 39 | color_b: Blue component (0-255). 40 | 41 | Returns: 42 | dict: Result of the operation. 43 | 44 | """ 45 | # Sanitize text input to ensure it's valid UTF-8 46 | try: 47 | # Ensure text is properly encoded/decoded 48 | if isinstance(text, bytes): 49 | text = text.decode("utf-8", errors="replace") 50 | else: 51 | # Force encode and decode to catch any encoding issues 52 | text = text.encode("utf-8", errors="replace").decode( 53 | "utf-8", errors="replace" 54 | ) 55 | print(f"Sanitized text: '{text}'") 56 | except Exception as e: 57 | print(f"Error sanitizing text: {e}") 58 | return { 59 | "success": False, 60 | "error": f"Invalid text encoding: {e!s}", 61 | "detailed_error": ( 62 | "The text provided contains invalid characters that cannot be properly encoded in UTF-8. " 63 | "Please check the text and try again with valid characters." 64 | ), 65 | } 66 | 67 | ps_app = PhotoshopApp() 68 | doc = ps_app.get_active_document() 69 | if not doc: 70 | return {"success": False, "error": "No active document"} 71 | 72 | try: 73 | print( 74 | f"Creating text layer: text='{text}', position=({x}, {y}), " 75 | f"size={size}, color=({color_r}, {color_g}, {color_b})" 76 | ) 77 | 78 | # Create text layer 79 | print("Adding art layer") 80 | text_layer = doc.artLayers.add() 81 | print("Setting layer kind to TextLayer") 82 | text_layer.kind = ps.LayerKind.TextLayer 83 | 84 | # Configure text 85 | print("Configuring text item") 86 | text_item = text_layer.textItem 87 | text_item.contents = text 88 | text_item.position = [x, y] 89 | text_item.size = size 90 | 91 | # Configure color 92 | print("Setting text color") 93 | text_color = ps.SolidColor() 94 | text_color.rgb.red = color_r 95 | text_color.rgb.green = color_g 96 | text_color.rgb.blue = color_b 97 | text_item.color = text_color 98 | 99 | print(f"Text layer created successfully: {text_layer.name}") 100 | return {"success": True, "layer_name": text_layer.name} 101 | except Exception as e: 102 | print(f"Error creating text layer: {e}") 103 | import traceback 104 | 105 | tb_text = traceback.format_exc() 106 | traceback.print_exc() 107 | 108 | # Create a detailed error message 109 | detailed_error = ( 110 | f"Error creating text layer with parameters:\n" 111 | f" text: {text}\n" 112 | f" position: ({x}, {y})\n" 113 | f" size: {size}\n" 114 | f" color: ({color_r}, {color_g}, {color_b})\n\n" 115 | f"Error: {e!s}\n\n" 116 | f"Traceback:\n{tb_text}" 117 | ) 118 | 119 | return { 120 | "success": False, 121 | "error": str(e), 122 | "detailed_error": detailed_error, 123 | "parameters": { 124 | "text": text, 125 | "x": x, 126 | "y": y, 127 | "size": size, 128 | "color": [color_r, color_g, color_b], 129 | }, 130 | } 131 | 132 | # Register the create_text_layer function with a specific name 133 | tool_name = register_tool(mcp, create_text_layer, "create_text_layer") 134 | registered_tools.append(tool_name) 135 | 136 | def create_solid_color_layer( 137 | color_r: int = 255, color_g: int = 0, color_b: int = 0, name: str = "Color Fill" 138 | ) -> dict: 139 | """Create a solid color fill layer. 140 | 141 | Args: 142 | color_r: Red component (0-255). 143 | color_g: Green component (0-255). 144 | color_b: Blue component (0-255). 145 | name: Layer name. 146 | 147 | Returns: 148 | dict: Result of the operation. 149 | 150 | """ 151 | # Sanitize name input to ensure it's valid UTF-8 152 | try: 153 | # Ensure name is properly encoded/decoded 154 | if isinstance(name, bytes): 155 | name = name.decode("utf-8", errors="replace") 156 | else: 157 | # Force encode and decode to catch any encoding issues 158 | name = name.encode("utf-8", errors="replace").decode( 159 | "utf-8", errors="replace" 160 | ) 161 | print(f"Sanitized layer name: '{name}'") 162 | except Exception as e: 163 | print(f"Error sanitizing layer name: {e}") 164 | return { 165 | "success": False, 166 | "error": f"Invalid name encoding: {e!s}", 167 | "detailed_error": ( 168 | "The layer name provided contains invalid characters that cannot be properly encoded in UTF-8. " 169 | "Please check the name and try again with valid characters." 170 | ), 171 | } 172 | 173 | ps_app = PhotoshopApp() 174 | doc = ps_app.get_active_document() 175 | if not doc: 176 | return {"success": False, "error": "No active document"} 177 | 178 | try: 179 | print( 180 | f"Creating solid color layer: name='{name}', color=({color_r}, {color_g}, {color_b})" 181 | ) 182 | 183 | # Escape special characters in the name for JavaScript 184 | escaped_name = ( 185 | name.replace('"', '\\"') 186 | .replace("'", "\\'") 187 | .replace("\n", "\\n") 188 | .replace("\r", "\\r") 189 | ) 190 | 191 | # Create a solid color fill layer using JavaScript 192 | js_script = f""" 193 | try {{ 194 | var doc = app.activeDocument; 195 | var newLayer = doc.artLayers.add(); 196 | newLayer.name = "{escaped_name}"; 197 | 198 | // Create a solid color fill 199 | var solidColor = new SolidColor(); 200 | solidColor.rgb.red = {color_r}; 201 | solidColor.rgb.green = {color_g}; 202 | solidColor.rgb.blue = {color_b}; 203 | 204 | // Fill the layer with the color 205 | doc.selection.selectAll(); 206 | doc.selection.fill(solidColor); 207 | doc.selection.deselect(); 208 | 'success'; 209 | }} catch(e) {{ 210 | 'Error: ' + e.toString(); 211 | }} 212 | """ 213 | 214 | print("Executing JavaScript to create solid color layer") 215 | result = ps_app.execute_javascript(js_script) 216 | print(f"JavaScript execution result: {result}") 217 | 218 | # Check if JavaScript returned an error 219 | if result and isinstance(result, str) and result.startswith("Error:"): 220 | return { 221 | "success": False, 222 | "error": result, 223 | "detailed_error": f"JavaScript error while creating solid color layer: {result}", 224 | } 225 | 226 | print(f"Solid color layer created successfully: {name}") 227 | return {"success": True, "layer_name": name} 228 | except Exception as e: 229 | print(f"Error creating solid color layer: {e}") 230 | import traceback 231 | 232 | tb_text = traceback.format_exc() 233 | traceback.print_exc() 234 | 235 | # Create a detailed error message 236 | detailed_error = ( 237 | f"Error creating solid color layer with parameters:\n" 238 | f" name: {name}\n" 239 | f" color: ({color_r}, {color_g}, {color_b})\n\n" 240 | f"Error: {e!s}\n\n" 241 | f"Traceback:\n{tb_text}" 242 | ) 243 | 244 | return { 245 | "success": False, 246 | "error": str(e), 247 | "detailed_error": detailed_error, 248 | "parameters": {"name": name, "color": [color_r, color_g, color_b]}, 249 | } 250 | 251 | # Register the create_solid_color_layer function with a specific name 252 | tool_name = register_tool(mcp, create_solid_color_layer, "create_solid_color_layer") 253 | registered_tools.append(tool_name) 254 | 255 | # Return the list of registered tools 256 | return registered_tools 257 | -------------------------------------------------------------------------------- /photoshop_mcp_server/ps_adapter/application.py: -------------------------------------------------------------------------------- 1 | """Photoshop application adapter.""" 2 | 3 | from typing import Optional 4 | 5 | import photoshop.api as ps 6 | from photoshop import Session 7 | 8 | 9 | class PhotoshopApp: 10 | """Adapter for the Photoshop application. 11 | 12 | This class implements the Singleton pattern to ensure only one instance 13 | of the Photoshop application is created. 14 | """ 15 | 16 | _instance: Optional["PhotoshopApp"] = None 17 | 18 | def __new__(cls): 19 | """Create a new instance or return the existing one.""" 20 | if cls._instance is None: 21 | cls._instance = super().__new__(cls) 22 | cls._instance._initialized = False 23 | return cls._instance 24 | 25 | def __init__(self): 26 | """Initialize the Photoshop application.""" 27 | if not getattr(self, "_initialized", False): 28 | try: 29 | # Create a session with new_document action 30 | self.session = Session(action="new_document", auto_close=False) 31 | self.app = self.session.app 32 | except Exception: 33 | # Fallback to direct Application if Session fails 34 | self.app = ps.Application() 35 | self._initialized = True 36 | 37 | def get_version(self): 38 | """Get the Photoshop version. 39 | 40 | Returns: 41 | str: The Photoshop version. 42 | 43 | """ 44 | return self.app.version 45 | 46 | def get_active_document(self): 47 | """Get the active document. 48 | 49 | Returns: 50 | Document or None: The active document or None if no document is open. 51 | 52 | """ 53 | try: 54 | if hasattr(self, "session"): 55 | return self.session.active_document 56 | return ( 57 | self.app.activeDocument if hasattr(self.app, "activeDocument") else None 58 | ) 59 | except Exception: 60 | return None 61 | 62 | def create_document( 63 | self, width=1000, height=1000, resolution=72, name="Untitled", mode="rgb" 64 | ): 65 | """Create a new document. 66 | 67 | Args: 68 | width (int, optional): Document width in pixels. Defaults to 1000. 69 | height (int, optional): Document height in pixels. Defaults to 1000. 70 | resolution (int, optional): Document resolution in PPI. Defaults to 72. 71 | name (str, optional): Document name. Defaults to "Untitled". 72 | mode (str, optional): Color mode (rgb, cmyk, etc.). Defaults to "rgb". 73 | 74 | Returns: 75 | Document: The created document. 76 | 77 | """ 78 | print( 79 | f"PhotoshopApp.create_document called with: width={width}, height={height}, " 80 | f"resolution={resolution}, name={name}, mode={mode}" 81 | ) 82 | 83 | # Ensure mode is lowercase for consistency 84 | mode = mode.lower() if isinstance(mode, str) else "rgb" 85 | print(f"Normalized mode: {mode}") 86 | 87 | # Get the NewDocumentMode enum value 88 | try: 89 | # Map mode string to correct enum name 90 | mode_map = { 91 | "rgb": "NewRGB", 92 | "cmyk": "NewCMYK", 93 | "grayscale": "NewGray", 94 | "gray": "NewGray", 95 | "bitmap": "NewBitmap", 96 | "lab": "NewLab", 97 | } 98 | 99 | # Get the correct enum name or default to NewRGB 100 | enum_name = mode_map.get(mode.lower(), "NewRGB") 101 | print(f"Getting NewDocumentMode enum for: {mode.lower()} -> {enum_name}") 102 | 103 | # Get the enum value 104 | mode_enum = getattr(ps.NewDocumentMode, enum_name) 105 | print(f"Mode enum: {mode_enum}") 106 | except (AttributeError, TypeError) as e: 107 | print(f"Error getting mode enum: {e}, defaulting to NewRGB") 108 | # Default to NewRGB if mode is invalid 109 | mode_enum = ps.NewDocumentMode.NewRGB 110 | 111 | try: 112 | if hasattr(self, "session"): 113 | print("Using session-based approach") 114 | # Close any existing document 115 | if ( 116 | hasattr(self.session, "active_document") 117 | and self.session.active_document 118 | ): 119 | try: 120 | print("Closing existing document") 121 | self.session.active_document.close() 122 | except Exception as close_error: 123 | print(f"Error closing document: {close_error}") 124 | pass 125 | # Create a new session with new_document action 126 | print("Creating new session with new_document action") 127 | self.session = Session(action="new_document", auto_close=False) 128 | # Set document properties 129 | print("Getting active document from session") 130 | doc = self.session.active_document 131 | print( 132 | f"Document created via session: {doc.name if hasattr(doc, 'name') else 'Unknown'}" 133 | ) 134 | return doc 135 | else: 136 | print("Using direct Application approach") 137 | print( 138 | f"Adding document with params: width={width}, height={height}, " 139 | f"resolution={resolution}, name={name}, mode_enum={mode_enum}" 140 | ) 141 | doc = self.app.documents.add(width, height, resolution, name, mode_enum) 142 | print( 143 | f"Document created via direct app: {doc.name if hasattr(doc, 'name') else 'Unknown'}" 144 | ) 145 | return doc 146 | except Exception as e: 147 | # Log the exception for debugging 148 | print(f"Error creating document: {e!s}") 149 | import traceback 150 | 151 | traceback.print_exc() 152 | 153 | # Fallback to direct Application if Session fails 154 | try: 155 | print("Trying fallback to direct Application") 156 | doc = self.app.documents.add(width, height, resolution, name, mode_enum) 157 | print( 158 | f"Document created via fallback: {doc.name if hasattr(doc, 'name') else 'Unknown'}" 159 | ) 160 | return doc 161 | except Exception as e2: 162 | print(f"Fallback also failed: {e2!s}") 163 | traceback.print_exc() 164 | 165 | # Last resort: try with just the basic parameters 166 | try: 167 | print("Trying last resort with basic parameters") 168 | doc = self.app.documents.add(width, height) 169 | print( 170 | f"Document created via last resort: {doc.name if hasattr(doc, 'name') else 'Unknown'}" 171 | ) 172 | return doc 173 | except Exception as e3: 174 | print(f"Last resort also failed: {e3!s}") 175 | traceback.print_exc() 176 | # Create a detailed error message with all attempts 177 | detailed_error = ( 178 | f"Failed to create document with mode '{mode}'\n\n" 179 | f"First attempt error: {e!s}\n" 180 | f"Fallback attempt error: {e2!s}\n" 181 | f"Last resort error: {e3!s}" 182 | ) 183 | # Raise a more informative exception 184 | raise RuntimeError(detailed_error) from e3 185 | 186 | def open_document(self, file_path): 187 | """Open an existing document. 188 | 189 | Args: 190 | file_path (str): Path to the document file. 191 | 192 | Returns: 193 | Document: The opened document. 194 | 195 | """ 196 | try: 197 | if hasattr(self, "session"): 198 | # Close any existing document 199 | if ( 200 | hasattr(self.session, "active_document") 201 | and self.session.active_document 202 | ): 203 | try: 204 | self.session.active_document.close() 205 | except Exception: 206 | pass 207 | # Create a new session with open action 208 | self.session = Session( 209 | file_path=file_path, action="open", auto_close=False 210 | ) 211 | # Return the active document 212 | return self.session.active_document 213 | else: 214 | return self.app.open(file_path) 215 | except Exception: 216 | # Fallback to direct Application if Session fails 217 | return self.app.open(file_path) 218 | 219 | def execute_javascript(self, script): 220 | """Execute JavaScript code in Photoshop. 221 | 222 | Args: 223 | script (str): JavaScript code to execute. 224 | 225 | Returns: 226 | str: The result of the JavaScript execution. 227 | 228 | """ 229 | # Ensure script returns a valid JSON string 230 | if not script.strip().endswith(";"): 231 | script = script.rstrip() + ";" 232 | 233 | # Make sure script returns a value 234 | if "return " not in script and "JSON.stringify" not in script: 235 | script = script + "\n'success';" # Add a default return value 236 | 237 | try: 238 | # Try to execute with default parameters 239 | result = self.app.doJavaScript(script) 240 | if result: 241 | return result 242 | return '{"success": true}' # Return a valid JSON if no result 243 | except Exception as e: 244 | print(f"Error executing JavaScript (attempt 1): {e}") 245 | 246 | # Check for specific COM error code -2147212704 247 | if "-2147212704" in str(e): 248 | print("Detected COM error -2147212704, trying alternative approach") 249 | # This is often a dialog-related error, try with a safer script 250 | safer_script = f""" 251 | try {{ 252 | // Disable dialogs 253 | var originalDialogMode = app.displayDialogs; 254 | app.displayDialogs = DialogModes.NO; 255 | 256 | // Execute the original script 257 | var result = (function() {{ 258 | {script} 259 | }})(); 260 | 261 | // Restore dialog mode 262 | app.displayDialogs = originalDialogMode; 263 | 264 | return result; 265 | }} catch(e) {{ 266 | return JSON.stringify({{ 267 | "error": e.toString(), 268 | "success": false 269 | }}); 270 | }} 271 | """ 272 | try: 273 | return self.app.doJavaScript(safer_script, None, 1) 274 | except Exception as e_safer: 275 | print(f"Safer script approach failed: {e_safer}") 276 | # Continue to other fallbacks 277 | 278 | try: 279 | # Try with explicit parameters 280 | # 1 = PsJavaScriptExecutionMode.psNormalMode 281 | result = self.app.doJavaScript(script, None, 1) 282 | if result: 283 | return result 284 | return '{"success": true}' # Return a valid JSON if no result 285 | except Exception as e2: 286 | print(f"Error executing JavaScript (attempt 2): {e2}") 287 | 288 | # Try with a different execution mode 289 | try: 290 | # 2 = PsJavaScriptExecutionMode.psInteractiveMode 291 | result = self.app.doJavaScript(script, None, 2) 292 | if result: 293 | return result 294 | return '{"success": true}' # Return a valid JSON if no result 295 | except Exception as e3: 296 | print(f"Error executing JavaScript (attempt 3): {e3}") 297 | 298 | # Last resort: wrap script in a try-catch block if not already wrapped 299 | if "try {" not in script: 300 | wrapped_script = f""" 301 | try {{ 302 | // Disable dialogs 303 | var originalDialogMode = app.displayDialogs; 304 | app.displayDialogs = DialogModes.NO; 305 | 306 | // Execute the original script 307 | var result = (function() {{ 308 | {script} 309 | }})(); 310 | 311 | // Restore dialog mode 312 | app.displayDialogs = originalDialogMode; 313 | 314 | return result; 315 | }} catch(e) {{ 316 | return JSON.stringify({{ 317 | "error": e.toString(), 318 | "success": false 319 | }}); 320 | }} 321 | """ 322 | try: 323 | result = self.app.doJavaScript(wrapped_script, None, 1) 324 | if result: 325 | return result 326 | return '{"success": true}' # Return a valid JSON if no result 327 | except Exception as e4: 328 | print(f"Error executing JavaScript (final attempt): {e4}") 329 | # Return a valid JSON with error information 330 | error_msg = str(e4).replace('"', '\\"') 331 | return '{"error": "' + error_msg + '", "success": false}' 332 | else: 333 | # Script already has try-catch, just return the error 334 | error_msg = str(e2).replace('"', '\\"') 335 | return '{"error": "' + error_msg + '", "success": false}' 336 | -------------------------------------------------------------------------------- /photoshop_mcp_server/ps_adapter/action_manager.py: -------------------------------------------------------------------------------- 1 | """Photoshop Action Manager utilities. 2 | 3 | This module provides utilities for working with Photoshop's Action Manager API, 4 | which is a lower-level API that is more stable than the JavaScript API. 5 | """ 6 | 7 | from typing import Any 8 | 9 | import photoshop.api as ps 10 | 11 | from photoshop_mcp_server.ps_adapter.application import PhotoshopApp 12 | 13 | 14 | class ActionManager: 15 | """Utility class for working with Photoshop's Action Manager API.""" 16 | 17 | @staticmethod 18 | def str_id_to_char_id(string_id: str) -> int: 19 | """Convert a string ID to a character ID. 20 | 21 | Args: 22 | string_id: The string ID to convert. 23 | 24 | Returns: 25 | The character ID. 26 | 27 | """ 28 | ps_app = PhotoshopApp() 29 | return ps_app.app.stringIDToTypeID(string_id) 30 | 31 | @staticmethod 32 | def char_id_to_type_id(char_id: str) -> int: 33 | """Convert a character ID to a type ID. 34 | 35 | Args: 36 | char_id: The character ID to convert. 37 | 38 | Returns: 39 | The type ID. 40 | 41 | """ 42 | ps_app = PhotoshopApp() 43 | return ps_app.app.charIDToTypeID(char_id) 44 | 45 | @classmethod 46 | def get_active_document_info(cls) -> dict[str, Any]: 47 | """Get information about the active document using Action Manager. 48 | 49 | Returns: 50 | A dictionary containing information about the active document, 51 | or an error message if no document is open. 52 | 53 | """ 54 | try: 55 | ps_app = PhotoshopApp() 56 | app = ps_app.app 57 | 58 | # Check if there's an active document 59 | if not hasattr(app, "documents") or not app.documents.length: 60 | return { 61 | "success": True, 62 | "error": "No active document", 63 | "no_document": True, 64 | } 65 | 66 | # Create a reference to the current document 67 | ref = ps.ActionReference() 68 | ref.putEnumerated( 69 | cls.char_id_to_type_id("Dcmn"), # Document 70 | cls.char_id_to_type_id("Ordn"), # Ordinal 71 | cls.char_id_to_type_id("Trgt"), # Target/Current 72 | ) 73 | 74 | # Get the document descriptor 75 | desc = app.executeActionGet(ref) 76 | 77 | # Extract basic document info 78 | result = { 79 | "success": True, 80 | "name": "", 81 | "width": 0, 82 | "height": 0, 83 | "resolution": 0, 84 | "mode": "", 85 | "color_mode": "", 86 | "bit_depth": 0, 87 | "layers": [], 88 | "layer_sets": [], 89 | "channels": [], 90 | "path": "", 91 | } 92 | 93 | # Get document properties safely 94 | try: 95 | if desc.hasKey(cls.str_id_to_char_id("title")): 96 | result["name"] = desc.getString(cls.str_id_to_char_id("title")) 97 | except Exception as e: 98 | print(f"Error getting document name: {e}") 99 | 100 | try: 101 | if desc.hasKey(cls.char_id_to_type_id("Wdth")): 102 | result["width"] = desc.getUnitDoubleValue( 103 | cls.char_id_to_type_id("Wdth") 104 | ) 105 | except Exception as e: 106 | print(f"Error getting document width: {e}") 107 | 108 | try: 109 | if desc.hasKey(cls.char_id_to_type_id("Hght")): 110 | result["height"] = desc.getUnitDoubleValue( 111 | cls.char_id_to_type_id("Hght") 112 | ) 113 | except Exception as e: 114 | print(f"Error getting document height: {e}") 115 | 116 | try: 117 | if desc.hasKey(cls.char_id_to_type_id("Rslt")): 118 | result["resolution"] = desc.getUnitDoubleValue( 119 | cls.char_id_to_type_id("Rslt") 120 | ) 121 | except Exception as e: 122 | print(f"Error getting document resolution: {e}") 123 | 124 | try: 125 | if desc.hasKey(cls.char_id_to_type_id("Md ")): 126 | mode_id = desc.getEnumerationValue(cls.char_id_to_type_id("Md ")) 127 | mode_map = { 128 | cls.char_id_to_type_id("Grys"): "Grayscale", 129 | cls.char_id_to_type_id("RGBM"): "RGB", 130 | cls.char_id_to_type_id("CMYM"): "CMYK", 131 | cls.char_id_to_type_id("LbCM"): "Lab", 132 | } 133 | result["mode"] = mode_map.get(mode_id, f"Unknown ({mode_id})") 134 | result["color_mode"] = result["mode"] 135 | except Exception as e: 136 | print(f"Error getting document mode: {e}") 137 | 138 | try: 139 | if desc.hasKey(cls.char_id_to_type_id("Dpth")): 140 | result["bit_depth"] = desc.getInteger( 141 | cls.char_id_to_type_id("Dpth") 142 | ) 143 | except Exception as e: 144 | print(f"Error getting document bit depth: {e}") 145 | 146 | try: 147 | if desc.hasKey(cls.str_id_to_char_id("fileReference")): 148 | file_ref = desc.getPath(cls.str_id_to_char_id("fileReference")) 149 | result["path"] = str(file_ref) 150 | except Exception as e: 151 | print(f"Error getting document path: {e}") 152 | 153 | # Get layers info would require more complex Action Manager code 154 | # This is a simplified implementation 155 | 156 | return result 157 | 158 | except Exception as e: 159 | import traceback 160 | 161 | tb_text = traceback.format_exc() 162 | print(f"Error in get_active_document_info: {e}") 163 | print(tb_text) 164 | return {"success": False, "error": str(e), "detailed_error": tb_text} 165 | 166 | @classmethod 167 | def get_selection_info(cls) -> dict[str, Any]: 168 | """Get information about the current selection using Action Manager. 169 | 170 | Returns: 171 | A dictionary containing information about the current selection, 172 | or an indication that there is no selection. 173 | 174 | """ 175 | try: 176 | ps_app = PhotoshopApp() 177 | app = ps_app.app 178 | 179 | # Check if there's an active document 180 | if not hasattr(app, "documents") or not app.documents.length: 181 | return { 182 | "success": True, 183 | "has_selection": False, 184 | "error": "No active document", 185 | } 186 | 187 | # Create a reference to check if there's a selection 188 | ref = ps.ActionReference() 189 | ref.putProperty( 190 | cls.char_id_to_type_id("Prpr"), # Property 191 | cls.char_id_to_type_id("PixL"), # Pixel Selection 192 | ) 193 | ref.putEnumerated( 194 | cls.char_id_to_type_id("Dcmn"), # Document 195 | cls.char_id_to_type_id("Ordn"), # Ordinal 196 | cls.char_id_to_type_id("Trgt"), # Target/Current 197 | ) 198 | 199 | # Try to get the selection 200 | try: 201 | # If this doesn't throw an error, there's a selection 202 | app.executeActionGet(ref) 203 | # We don't need to store the result, just check if it throws an exception 204 | except Exception: 205 | # No selection 206 | return {"success": True, "has_selection": False} 207 | 208 | # If we get here, there is a selection 209 | # Get the bounds of the selection 210 | bounds_ref = ps.ActionReference() 211 | bounds_ref.putProperty( 212 | cls.char_id_to_type_id("Prpr"), # Property 213 | cls.str_id_to_char_id("bounds"), # Bounds 214 | ) 215 | bounds_ref.putEnumerated( 216 | cls.char_id_to_type_id("csel"), # Current Selection 217 | cls.char_id_to_type_id("Ordn"), # Ordinal 218 | cls.char_id_to_type_id("Trgt"), # Target/Current 219 | ) 220 | 221 | try: 222 | bounds_desc = app.executeActionGet(bounds_ref) 223 | if bounds_desc.hasKey(cls.str_id_to_char_id("bounds")): 224 | bounds = bounds_desc.getObjectValue(cls.str_id_to_char_id("bounds")) 225 | 226 | # Extract bounds values 227 | left = bounds.getUnitDoubleValue(cls.char_id_to_type_id("Left")) 228 | top = bounds.getUnitDoubleValue(cls.char_id_to_type_id("Top ")) 229 | right = bounds.getUnitDoubleValue(cls.char_id_to_type_id("Rght")) 230 | bottom = bounds.getUnitDoubleValue(cls.char_id_to_type_id("Btom")) 231 | 232 | # Calculate dimensions 233 | width = right - left 234 | height = bottom - top 235 | 236 | return { 237 | "success": True, 238 | "has_selection": True, 239 | "bounds": { 240 | "left": left, 241 | "top": top, 242 | "right": right, 243 | "bottom": bottom, 244 | }, 245 | "width": width, 246 | "height": height, 247 | "area": width * height, 248 | } 249 | except Exception as e: 250 | print(f"Error getting selection bounds: {e}") 251 | return { 252 | "success": True, 253 | "has_selection": True, 254 | "error": f"Selection exists but couldn't get bounds: {e!s}", 255 | } 256 | 257 | # Fallback if we couldn't get bounds 258 | return {"success": True, "has_selection": True} 259 | 260 | except Exception as e: 261 | import traceback 262 | 263 | tb_text = traceback.format_exc() 264 | print(f"Error in get_selection_info: {e}") 265 | print(tb_text) 266 | return { 267 | "success": False, 268 | "has_selection": False, 269 | "error": str(e), 270 | "detailed_error": tb_text, 271 | } 272 | 273 | @classmethod 274 | def get_session_info(cls) -> dict[str, Any]: 275 | """Get information about the current Photoshop session using Action Manager. 276 | 277 | Returns: 278 | A dictionary containing information about the current Photoshop session. 279 | 280 | """ 281 | try: 282 | ps_app = PhotoshopApp() 283 | app = ps_app.app 284 | 285 | # Get basic application info 286 | info = { 287 | "success": True, 288 | "is_running": True, 289 | "version": app.version, 290 | "build": getattr(app, "build", ""), 291 | "has_active_document": False, 292 | "documents": [], 293 | "active_document": None, 294 | "preferences": {}, 295 | } 296 | 297 | # Get document info 298 | doc_info = cls.get_active_document_info() 299 | if doc_info.get("success", False) and not doc_info.get( 300 | "no_document", False 301 | ): 302 | info["has_active_document"] = True 303 | info["active_document"] = doc_info 304 | 305 | # Get all documents 306 | docs = [] 307 | for i in range(app.documents.length): 308 | try: 309 | # Create a reference to the document 310 | doc_ref = ps.ActionReference() 311 | doc_ref.putIndex( 312 | cls.char_id_to_type_id("Dcmn"), # Document 313 | i + 1, # 1-based index 314 | ) 315 | 316 | # Get the document descriptor 317 | doc_desc = app.executeActionGet(doc_ref) 318 | 319 | # Extract basic info 320 | doc_info = { 321 | "name": "", 322 | "width": 0, 323 | "height": 0, 324 | "is_active": False, 325 | } 326 | 327 | # Get document name 328 | if doc_desc.hasKey(cls.str_id_to_char_id("title")): 329 | doc_info["name"] = doc_desc.getString( 330 | cls.str_id_to_char_id("title") 331 | ) 332 | 333 | # Check if this is the active document 334 | doc_info["is_active"] = ( 335 | doc_info["name"] == info["active_document"]["name"] 336 | ) 337 | 338 | # Get dimensions 339 | if doc_desc.hasKey(cls.char_id_to_type_id("Wdth")): 340 | doc_info["width"] = doc_desc.getUnitDoubleValue( 341 | cls.char_id_to_type_id("Wdth") 342 | ) 343 | if doc_desc.hasKey(cls.char_id_to_type_id("Hght")): 344 | doc_info["height"] = doc_desc.getUnitDoubleValue( 345 | cls.char_id_to_type_id("Hght") 346 | ) 347 | 348 | docs.append(doc_info) 349 | except Exception as e: 350 | print(f"Error getting document {i} info: {e}") 351 | 352 | info["documents"] = docs 353 | 354 | # Get preferences 355 | try: 356 | # Create a reference to the application 357 | app_ref = ps.ActionReference() 358 | app_ref.putProperty( 359 | cls.char_id_to_type_id("Prpr"), # Property 360 | cls.str_id_to_char_id("generalPreferences"), # General Preferences 361 | ) 362 | app_ref.putEnumerated( 363 | cls.char_id_to_type_id("capp"), # Current Application 364 | cls.char_id_to_type_id("Ordn"), # Ordinal 365 | cls.char_id_to_type_id("Trgt"), # Target/Current 366 | ) 367 | 368 | # Get the application descriptor 369 | app_desc = app.executeActionGet(app_ref) 370 | 371 | # Extract preferences 372 | prefs = {} 373 | 374 | # Get ruler units 375 | if app_desc.hasKey(cls.str_id_to_char_id("rulerUnits")): 376 | ruler_id = app_desc.getEnumerationValue( 377 | cls.str_id_to_char_id("rulerUnits") 378 | ) 379 | ruler_map = { 380 | cls.char_id_to_type_id("Pxl"): "Pixels", 381 | cls.char_id_to_type_id("Inch"): "Inches", 382 | cls.char_id_to_type_id("Centimeter"): "Centimeters", 383 | cls.char_id_to_type_id("Millimeter"): "Millimeters", 384 | cls.char_id_to_type_id("Pnt"): "Points", 385 | cls.char_id_to_type_id("Pica"): "Picas", 386 | cls.char_id_to_type_id("Percent"): "Percent", 387 | } 388 | prefs["ruler_units"] = ruler_map.get( 389 | ruler_id, f"Unknown ({ruler_id})" 390 | ) 391 | 392 | # Get type units 393 | if app_desc.hasKey(cls.str_id_to_char_id("typeUnits")): 394 | type_id = app_desc.getEnumerationValue( 395 | cls.str_id_to_char_id("typeUnits") 396 | ) 397 | type_map = { 398 | cls.char_id_to_type_id("Pxl"): "Pixels", 399 | cls.char_id_to_type_id("Pnt"): "Points", 400 | cls.char_id_to_type_id("Millimeter"): "Millimeters", 401 | } 402 | prefs["type_units"] = type_map.get(type_id, f"Unknown ({type_id})") 403 | 404 | info["preferences"] = prefs 405 | except Exception as e: 406 | print(f"Error getting preferences: {e}") 407 | 408 | return info 409 | 410 | except Exception as e: 411 | import traceback 412 | 413 | tb_text = traceback.format_exc() 414 | print(f"Error in get_session_info: {e}") 415 | print(tb_text) 416 | return { 417 | "success": False, 418 | "is_running": True, 419 | "error": str(e), 420 | "detailed_error": tb_text, 421 | } 422 | --------------------------------------------------------------------------------