psforge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
psforge/registry.py ADDED
@@ -0,0 +1,121 @@
1
+ """Tool and resource registration system for MCP server."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from loguru import logger
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+
12
+ def register_tool(mcp: FastMCP, func: Callable, name: str | None = None) -> str:
13
+ """Register a single tool function with the MCP server.
14
+
15
+ Args:
16
+ mcp: MCP server instance.
17
+ func: Tool function to register.
18
+ name: Optional tool name override (defaults to function name).
19
+
20
+ Returns:
21
+ The registered tool name.
22
+ """
23
+ tool_name = name or func.__name__
24
+ description = func.__doc__ or f"Tool: {tool_name}"
25
+
26
+ mcp.tool(name=tool_name, description=description.strip())(func)
27
+
28
+ logger.debug(f"Registered tool: {tool_name}")
29
+ return tool_name
30
+
31
+
32
+ def discover_and_register_tools(mcp: FastMCP) -> list[str]:
33
+ """Automatically discover and register all tools from the tools package.
34
+
35
+ Args:
36
+ mcp: MCP server instance.
37
+
38
+ Returns:
39
+ List of registered tool names.
40
+ """
41
+ registered_tools = []
42
+
43
+ # Import tools package
44
+ try:
45
+ import psforge.tools as tools_pkg
46
+ except ImportError as e:
47
+ logger.error(f"Failed to import tools package: {e}")
48
+ return registered_tools
49
+
50
+ # Get tools package path
51
+ tools_path = Path(tools_pkg.__file__).parent
52
+
53
+ # Iterate through all Python modules in tools/
54
+ for module_info in pkgutil.iter_modules([str(tools_path)]):
55
+ if module_info.name.startswith("_"):
56
+ continue
57
+
58
+ module_name = f"psforge.tools.{module_info.name}"
59
+
60
+ try:
61
+ module = importlib.import_module(module_name)
62
+
63
+ # Look for register() function
64
+ if hasattr(module, "register"):
65
+ logger.info(f"Registering tools from: {module_name}")
66
+ tool_names = module.register(mcp)
67
+ registered_tools.extend(tool_names)
68
+ else:
69
+ logger.warning(f"Module {module_name} has no register() function")
70
+
71
+ except Exception as e:
72
+ logger.error(f"Failed to register tools from {module_name}: {e}")
73
+
74
+ logger.info(f"Total tools registered: {len(registered_tools)}")
75
+ return registered_tools
76
+
77
+
78
+ def discover_and_register_resources(mcp: FastMCP) -> list[str]:
79
+ """Automatically discover and register all resources from the resources package.
80
+
81
+ Args:
82
+ mcp: MCP server instance.
83
+
84
+ Returns:
85
+ List of registered resource URIs.
86
+ """
87
+ registered_resources = []
88
+
89
+ # Import resources package
90
+ try:
91
+ import psforge.resources as resources_pkg
92
+ except ImportError as e:
93
+ logger.error(f"Failed to import resources package: {e}")
94
+ return registered_resources
95
+
96
+ # Get resources package path
97
+ resources_path = Path(resources_pkg.__file__).parent
98
+
99
+ # Iterate through all Python modules in resources/
100
+ for module_info in pkgutil.iter_modules([str(resources_path)]):
101
+ if module_info.name.startswith("_"):
102
+ continue
103
+
104
+ module_name = f"psforge.resources.{module_info.name}"
105
+
106
+ try:
107
+ module = importlib.import_module(module_name)
108
+
109
+ # Look for register() function
110
+ if hasattr(module, "register"):
111
+ logger.info(f"Registering resources from: {module_name}")
112
+ resource_uris = module.register(mcp)
113
+ registered_resources.extend(resource_uris)
114
+ else:
115
+ logger.warning(f"Module {module_name} has no register() function")
116
+
117
+ except Exception as e:
118
+ logger.error(f"Failed to register resources from {module_name}: {e}")
119
+
120
+ logger.info(f"Total resources registered: {len(registered_resources)}")
121
+ return registered_resources
@@ -0,0 +1,3 @@
1
+ """MCP resources for Photoshop state exposure."""
2
+
3
+ __all__ = []
@@ -0,0 +1,3 @@
1
+ """Resource registration utilities."""
2
+
3
+ __all__ = []
psforge/server.py ADDED
@@ -0,0 +1,79 @@
1
+ """PSForge MCP Server - Main entry point."""
2
+
3
+ import sys
4
+
5
+ from loguru import logger
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from psforge.app import __app_name__, __version__
9
+ from psforge.registry import discover_and_register_resources, discover_and_register_tools
10
+
11
+
12
+ def setup_logging():
13
+ """Configure logging with loguru."""
14
+ logger.remove() # Remove default handler
15
+
16
+ # Add stderr handler for general logging
17
+ logger.add(
18
+ sys.stderr,
19
+ level="INFO",
20
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
21
+ )
22
+
23
+ # Add file handler for detailed debug logs
24
+ logger.add(
25
+ "psforge_debug.log",
26
+ level="DEBUG",
27
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
28
+ rotation="10 MB",
29
+ retention="7 days",
30
+ )
31
+
32
+
33
+ def run():
34
+ """Entry point for the CLI command."""
35
+ setup_logging()
36
+
37
+ logger.info(f"Starting {__app_name__} v{__version__}")
38
+ logger.info("Photoshop MCP Server - AI-Powered Automation")
39
+
40
+ # Create MCP server using FastMCP
41
+ mcp = FastMCP(__app_name__)
42
+
43
+ # Discover and register all tools
44
+ logger.info("Discovering and registering tools...")
45
+ tool_names = discover_and_register_tools(mcp)
46
+
47
+ if tool_names:
48
+ logger.info(f"Registered {len(tool_names)} tools:")
49
+ for name in sorted(tool_names):
50
+ logger.info(f" - {name}")
51
+ else:
52
+ logger.warning("No tools were registered!")
53
+
54
+ # Discover and register all resources
55
+ logger.info("Discovering and registering resources...")
56
+ resource_uris = discover_and_register_resources(mcp)
57
+
58
+ if resource_uris:
59
+ logger.info(f"Registered {len(resource_uris)} resources:")
60
+ for uri in sorted(resource_uris):
61
+ logger.info(f" - {uri}")
62
+ else:
63
+ logger.info("No resources registered (this is OK)")
64
+
65
+ # Run the server
66
+ logger.info("PSForge MCP Server is ready")
67
+ logger.info("Waiting for connections...")
68
+
69
+ try:
70
+ mcp.run(transport="stdio")
71
+ except KeyboardInterrupt:
72
+ logger.info("Server stopped by user")
73
+ except Exception as e:
74
+ logger.error(f"Server error: {e}")
75
+ sys.exit(1)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ run()
@@ -0,0 +1,3 @@
1
+ """MCP tools for Photoshop automation."""
2
+
3
+ __all__ = []
@@ -0,0 +1,149 @@
1
+ """Action and script execution tools - play actions, run custom scripts."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.decorators import debug_tool, log_tool_call
8
+ from psforge.ps_adapter.application import PhotoshopApp
9
+ from psforge.ps_adapter.utils import js_escape_string
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all action tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def play_action(action_name: str, action_set: str) -> dict[str, Any]:
27
+ """Execute a Photoshop action from an action set.
28
+
29
+ Args:
30
+ action_name: Name of the action to execute.
31
+ action_set: Name of the action set containing the action.
32
+
33
+ Returns:
34
+ dict: Operation result and context.
35
+ """
36
+ ps_app = PhotoshopApp()
37
+ doc = ps_app.get_active_document()
38
+
39
+ if not doc:
40
+ return {
41
+ "success": False,
42
+ "error": "No active document. Some actions require an open document.",
43
+ }
44
+
45
+ try:
46
+ # Escape strings for JavaScript
47
+ action_name_escaped = js_escape_string(action_name)
48
+ action_set_escaped = js_escape_string(action_set)
49
+
50
+ # Execute action via JavaScript
51
+ action_script = f"""
52
+ (function() {{
53
+ try {{
54
+ app.doAction("{action_name_escaped}", "{action_set_escaped}");
55
+ return "Action executed successfully";
56
+ }} catch(e) {{
57
+ return "Error: " + e.toString();
58
+ }}
59
+ }})();
60
+ """
61
+
62
+ result = ps_app.execute_javascript(action_script)
63
+
64
+ if isinstance(result, str) and result.startswith("Error:"):
65
+ return {
66
+ "success": False,
67
+ "error": result,
68
+ "action_name": action_name,
69
+ "action_set": action_set,
70
+ }
71
+
72
+ return {
73
+ "success": True,
74
+ "message": f"Executed action '{action_name}' from set '{action_set}'",
75
+ "action_name": action_name,
76
+ "action_set": action_set,
77
+ "result": result,
78
+ }
79
+
80
+ except Exception as e:
81
+ logger.error(f"Failed to execute action: {e}")
82
+ return {
83
+ "success": False,
84
+ "error": str(e),
85
+ "action_name": action_name,
86
+ "action_set": action_set,
87
+ }
88
+
89
+ @debug_tool
90
+ @log_tool_call
91
+ def execute_script(script: str) -> dict[str, Any]:
92
+ """Execute arbitrary ExtendScript/JavaScript code in Photoshop.
93
+
94
+ This is a powerful tool that allows executing any valid Photoshop ExtendScript.
95
+ Use with caution - invalid scripts can cause errors or unexpected behavior.
96
+
97
+ Args:
98
+ script: JavaScript/ExtendScript code to execute in Photoshop.
99
+
100
+ Returns:
101
+ dict: Execution result and context.
102
+
103
+ Examples:
104
+ - Simple operation: "app.activeDocument.activeLayer.opacity = 50;"
105
+ - Get info: "app.activeDocument.layers.length"
106
+ - Complex: "(function() { var layer = app.activeDocument.activeLayer; layer.rotate(45); return 'Rotated'; })()"
107
+ """
108
+ ps_app = PhotoshopApp()
109
+
110
+ if not script or not script.strip():
111
+ return {
112
+ "success": False,
113
+ "error": "Script cannot be empty",
114
+ }
115
+
116
+ try:
117
+ logger.info(f"Executing custom script (length: {len(script)} chars)")
118
+ logger.debug(f"Script content:\n{script}")
119
+
120
+ # Execute the script
121
+ result = ps_app.execute_javascript(script)
122
+
123
+ # Convert result to string if it's not already
124
+ result_str = str(result) if result is not None else "undefined"
125
+
126
+ return {
127
+ "success": True,
128
+ "message": "Script executed successfully",
129
+ "result": result_str,
130
+ "script_length": len(script),
131
+ }
132
+
133
+ except Exception as e:
134
+ logger.error(f"Script execution failed: {e}")
135
+
136
+ # Try to extract useful error info
137
+ error_msg = str(e)
138
+
139
+ return {
140
+ "success": False,
141
+ "error": error_msg,
142
+ "script_preview": script[:200] + ("..." if len(script) > 200 else ""),
143
+ }
144
+
145
+ # Register all tools
146
+ registered_tools.append(register_tool(mcp, play_action, "play_action"))
147
+ registered_tools.append(register_tool(mcp, execute_script, "execute_script"))
148
+
149
+ return registered_tools
@@ -0,0 +1,316 @@
1
+ """Adjustment tools - brightness/contrast, hue/saturation, auto-levels, auto-contrast, desaturate, invert."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.decorators import debug_tool, log_tool_call
8
+ from psforge.ps_adapter.application import PhotoshopApp
9
+ from psforge.ps_adapter.utils import validate_numeric_range
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all adjustment tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def adjust_brightness_contrast(brightness: int, contrast: int) -> dict[str, Any]:
27
+ """Adjust brightness and contrast of the currently active layer.
28
+
29
+ Args:
30
+ brightness: Brightness adjustment (-150 to 150, 0 = no change).
31
+ contrast: Contrast adjustment (-50 to 100, 0 = no change).
32
+
33
+ Returns:
34
+ dict: Operation result and context.
35
+ """
36
+ validate_numeric_range(brightness, -150, 150, "brightness")
37
+ validate_numeric_range(contrast, -50, 100, "contrast")
38
+
39
+ ps_app = PhotoshopApp()
40
+ doc = ps_app.get_active_document()
41
+
42
+ if not doc:
43
+ return {
44
+ "success": False,
45
+ "error": "No active document",
46
+ }
47
+
48
+ try:
49
+ adjust_script = f"""
50
+ var layer = app.activeDocument.activeLayer;
51
+ var layerName = layer.name;
52
+
53
+ // Apply Brightness/Contrast
54
+ layer.adjustBrightnessContrast({brightness}, {contrast});
55
+
56
+ layerName;
57
+ """
58
+
59
+ layer_name = ps_app.execute_javascript(adjust_script)
60
+
61
+ return {
62
+ "success": True,
63
+ "message": f"Adjusted brightness ({brightness:+d}) and contrast ({contrast:+d}) on layer '{layer_name}'",
64
+ "layer_name": layer_name,
65
+ "brightness": brightness,
66
+ "contrast": contrast,
67
+ }
68
+
69
+ except Exception as e:
70
+ logger.error(f"Failed to adjust brightness/contrast: {e}")
71
+ return {
72
+ "success": False,
73
+ "error": str(e),
74
+ }
75
+
76
+ @debug_tool
77
+ @log_tool_call
78
+ def adjust_hue_saturation(hue: int, saturation: int, lightness: int) -> dict[str, Any]:
79
+ """Adjust hue, saturation, and lightness of the currently active layer.
80
+
81
+ Args:
82
+ hue: Hue shift in degrees (-180 to 180, 0 = no change).
83
+ saturation: Saturation adjustment (-100 to 100, 0 = no change).
84
+ lightness: Lightness adjustment (-100 to 100, 0 = no change).
85
+
86
+ Returns:
87
+ dict: Operation result and context.
88
+ """
89
+ validate_numeric_range(hue, -180, 180, "hue")
90
+ validate_numeric_range(saturation, -100, 100, "saturation")
91
+ validate_numeric_range(lightness, -100, 100, "lightness")
92
+
93
+ ps_app = PhotoshopApp()
94
+ doc = ps_app.get_active_document()
95
+
96
+ if not doc:
97
+ return {
98
+ "success": False,
99
+ "error": "No active document",
100
+ }
101
+
102
+ try:
103
+ adjust_script = f"""
104
+ var layer = app.activeDocument.activeLayer;
105
+ var layerName = layer.name;
106
+
107
+ // Apply Hue/Saturation
108
+ layer.adjustColorBalance([0, 0, 0], [0, 0, 0], [0, 0, 0], true);
109
+ layer.adjustHueSaturation({hue}, {saturation}, {lightness});
110
+
111
+ layerName;
112
+ """
113
+
114
+ layer_name = ps_app.execute_javascript(adjust_script)
115
+
116
+ return {
117
+ "success": True,
118
+ "message": f"Adjusted hue/saturation/lightness on layer '{layer_name}'",
119
+ "layer_name": layer_name,
120
+ "hue": hue,
121
+ "saturation": saturation,
122
+ "lightness": lightness,
123
+ }
124
+
125
+ except Exception as e:
126
+ logger.error(f"Failed to adjust hue/saturation: {e}")
127
+ return {
128
+ "success": False,
129
+ "error": str(e),
130
+ }
131
+
132
+ @debug_tool
133
+ @log_tool_call
134
+ def auto_levels() -> dict[str, Any]:
135
+ """Apply Auto Levels adjustment to the currently active layer.
136
+
137
+ Returns:
138
+ dict: Operation result and context.
139
+ """
140
+ ps_app = PhotoshopApp()
141
+ doc = ps_app.get_active_document()
142
+
143
+ if not doc:
144
+ return {
145
+ "success": False,
146
+ "error": "No active document",
147
+ }
148
+
149
+ try:
150
+ auto_levels_script = """
151
+ var layer = app.activeDocument.activeLayer;
152
+ var layerName = layer.name;
153
+
154
+ // Apply Auto Levels
155
+ layer.autoLevels();
156
+
157
+ layerName;
158
+ """
159
+
160
+ layer_name = ps_app.execute_javascript(auto_levels_script)
161
+
162
+ return {
163
+ "success": True,
164
+ "message": f"Applied Auto Levels to layer '{layer_name}'",
165
+ "layer_name": layer_name,
166
+ "adjustment": "Auto Levels",
167
+ }
168
+
169
+ except Exception as e:
170
+ logger.error(f"Failed to apply Auto Levels: {e}")
171
+ return {
172
+ "success": False,
173
+ "error": str(e),
174
+ }
175
+
176
+ @debug_tool
177
+ @log_tool_call
178
+ def auto_contrast() -> dict[str, Any]:
179
+ """Apply Auto Contrast adjustment to the currently active layer.
180
+
181
+ Returns:
182
+ dict: Operation result and context.
183
+ """
184
+ ps_app = PhotoshopApp()
185
+ doc = ps_app.get_active_document()
186
+
187
+ if not doc:
188
+ return {
189
+ "success": False,
190
+ "error": "No active document",
191
+ }
192
+
193
+ try:
194
+ auto_contrast_script = """
195
+ var layer = app.activeDocument.activeLayer;
196
+ var layerName = layer.name;
197
+
198
+ // Apply Auto Contrast
199
+ layer.autoContrast();
200
+
201
+ layerName;
202
+ """
203
+
204
+ layer_name = ps_app.execute_javascript(auto_contrast_script)
205
+
206
+ return {
207
+ "success": True,
208
+ "message": f"Applied Auto Contrast to layer '{layer_name}'",
209
+ "layer_name": layer_name,
210
+ "adjustment": "Auto Contrast",
211
+ }
212
+
213
+ except Exception as e:
214
+ logger.error(f"Failed to apply Auto Contrast: {e}")
215
+ return {
216
+ "success": False,
217
+ "error": str(e),
218
+ }
219
+
220
+ @debug_tool
221
+ @log_tool_call
222
+ def desaturate() -> dict[str, Any]:
223
+ """Desaturate (convert to grayscale) the currently active layer.
224
+
225
+ Returns:
226
+ dict: Operation result and context.
227
+ """
228
+ ps_app = PhotoshopApp()
229
+ doc = ps_app.get_active_document()
230
+
231
+ if not doc:
232
+ return {
233
+ "success": False,
234
+ "error": "No active document",
235
+ }
236
+
237
+ try:
238
+ desaturate_script = """
239
+ var layer = app.activeDocument.activeLayer;
240
+ var layerName = layer.name;
241
+
242
+ // Desaturate
243
+ layer.desaturate();
244
+
245
+ layerName;
246
+ """
247
+
248
+ layer_name = ps_app.execute_javascript(desaturate_script)
249
+
250
+ return {
251
+ "success": True,
252
+ "message": f"Desaturated layer '{layer_name}'",
253
+ "layer_name": layer_name,
254
+ "adjustment": "Desaturate",
255
+ }
256
+
257
+ except Exception as e:
258
+ logger.error(f"Failed to desaturate: {e}")
259
+ return {
260
+ "success": False,
261
+ "error": str(e),
262
+ }
263
+
264
+ @debug_tool
265
+ @log_tool_call
266
+ def invert() -> dict[str, Any]:
267
+ """Invert colors of the currently active layer.
268
+
269
+ Returns:
270
+ dict: Operation result and context.
271
+ """
272
+ ps_app = PhotoshopApp()
273
+ doc = ps_app.get_active_document()
274
+
275
+ if not doc:
276
+ return {
277
+ "success": False,
278
+ "error": "No active document",
279
+ }
280
+
281
+ try:
282
+ invert_script = """
283
+ var layer = app.activeDocument.activeLayer;
284
+ var layerName = layer.name;
285
+
286
+ // Invert
287
+ layer.invert();
288
+
289
+ layerName;
290
+ """
291
+
292
+ layer_name = ps_app.execute_javascript(invert_script)
293
+
294
+ return {
295
+ "success": True,
296
+ "message": f"Inverted colors of layer '{layer_name}'",
297
+ "layer_name": layer_name,
298
+ "adjustment": "Invert",
299
+ }
300
+
301
+ except Exception as e:
302
+ logger.error(f"Failed to invert: {e}")
303
+ return {
304
+ "success": False,
305
+ "error": str(e),
306
+ }
307
+
308
+ # Register all tools
309
+ registered_tools.append(register_tool(mcp, adjust_brightness_contrast, "adjust_brightness_contrast"))
310
+ registered_tools.append(register_tool(mcp, adjust_hue_saturation, "adjust_hue_saturation"))
311
+ registered_tools.append(register_tool(mcp, auto_levels, "auto_levels"))
312
+ registered_tools.append(register_tool(mcp, auto_contrast, "auto_contrast"))
313
+ registered_tools.append(register_tool(mcp, desaturate, "desaturate"))
314
+ registered_tools.append(register_tool(mcp, invert, "invert"))
315
+
316
+ return registered_tools