Merge pull request #106 from Tanichael/feature/manage-shader

Feat: Add CRUD operations for Shader files via MCP

Example Prompt: "generate a cool shader and apply it on a new cube"
main
Shutong Wu 2025-07-14 01:45:29 -04:00 committed by GitHub
commit 6a47945479
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 430 additions and 2 deletions

View File

@ -35,6 +35,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte
* `manage_editor`: Controls and queries the editor's state and settings.
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
* `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
</details>
@ -89,7 +90,9 @@ Unity MCP connects your tools using two components:
Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1.
**Option A: Auto-Configure (Recommended for Claude/Cursor)**
<img width="609" alt="image" src="https://github.com/user-attachments/assets/cef3a639-4677-4fd8-84e7-2d82a04d55bb" />
**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)**
1. In Unity, go to `Window > Unity MCP`.
2. Click `Auto Configure` on the IDE you uses.

View File

@ -20,6 +20,7 @@ namespace UnityMcpBridge.Editor.Tools
{ "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand},
};
/// <summary>

View File

@ -0,0 +1,342 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers;
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles CRUD operations for shader files within the Unity project.
/// </summary>
public static class ManageShader
{
/// <summary>
/// Main handler for shader management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
// Extract parameters
string action = @params["action"]?.ToString().ToLower();
string name = @params["name"]?.ToString();
string path = @params["path"]?.ToString(); // Relative to Assets/
string contents = null;
// Check if we have base64 encoded contents
bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false;
if (contentsEncoded && @params["encodedContents"] != null)
{
try
{
contents = DecodeBase64(@params["encodedContents"].ToString());
}
catch (Exception e)
{
return Response.Error($"Failed to decode shader contents: {e.Message}");
}
}
else
{
contents = @params["contents"]?.ToString();
}
// Validate required parameters
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
if (string.IsNullOrEmpty(name))
{
return Response.Error("Name parameter is required.");
}
// Basic name validation (alphanumeric, underscores, cannot start with number)
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return Response.Error(
$"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."
);
}
// Ensure path is relative to Assets/, removing any leading "Assets/"
// Set default directory to "Shaders" if path is not provided
string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = relativeDir.Replace('\\', '/').Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
}
// Handle empty string case explicitly after processing
if (string.IsNullOrEmpty(relativeDir))
{
relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/"
}
// Construct paths
string shaderFileName = $"{name}.shader";
string fullPathDir = Path.Combine(Application.dataPath, relativeDir);
string fullPath = Path.Combine(fullPathDir, shaderFileName);
string relativePath = Path.Combine("Assets", relativeDir, shaderFileName)
.Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes
// Ensure the target directory exists for create/update
if (action == "create" || action == "update")
{
try
{
if (!Directory.Exists(fullPathDir))
{
Directory.CreateDirectory(fullPathDir);
// Refresh AssetDatabase to recognize new folders
AssetDatabase.Refresh();
}
}
catch (Exception e)
{
return Response.Error(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
// Route to specific action handlers
switch (action)
{
case "create":
return CreateShader(fullPath, relativePath, name, contents);
case "read":
return ReadShader(fullPath, relativePath);
case "update":
return UpdateShader(fullPath, relativePath, name, contents);
case "delete":
return DeleteShader(fullPath, relativePath);
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions are: create, read, update, delete."
);
}
}
/// <summary>
/// Decode base64 string to normal text
/// </summary>
private static string DecodeBase64(string encoded)
{
byte[] data = Convert.FromBase64String(encoded);
return System.Text.Encoding.UTF8.GetString(data);
}
/// <summary>
/// Encode text to base64 string
/// </summary>
private static string EncodeBase64(string text)
{
byte[] data = System.Text.Encoding.UTF8.GetBytes(text);
return Convert.ToBase64String(data);
}
private static object CreateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
// Check if shader already exists
if (File.Exists(fullPath))
{
return Response.Error(
$"Shader already exists at '{relativePath}'. Use 'update' action to modify."
);
}
// Add validation for shader name conflicts in Unity
if (Shader.Find(name) != null)
{
return Response.Error(
$"A shader with name '{name}' already exists in the project. Choose a different name."
);
}
// Generate default content if none provided
if (string.IsNullOrEmpty(contents))
{
contents = GenerateDefaultShaderContent(name);
}
try
{
File.WriteAllText(fullPath, contents);
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader
return Response.Success(
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
catch (Exception e)
{
return Response.Error($"Failed to create shader '{relativePath}': {e.Message}");
}
}
private static object ReadShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return Response.Error($"Shader not found at '{relativePath}'.");
}
try
{
string contents = File.ReadAllText(fullPath);
// Return both normal and encoded contents for larger files
//TODO: Consider a threshold for large files
bool isLarge = contents.Length > 10000; // If content is large, include encoded version
var responseData = new
{
path = relativePath,
contents = contents,
// For large files, also include base64-encoded version
encodedContents = isLarge ? EncodeBase64(contents) : null,
contentsEncoded = isLarge,
};
return Response.Success(
$"Shader '{Path.GetFileName(relativePath)}' read successfully.",
responseData
);
}
catch (Exception e)
{
return Response.Error($"Failed to read shader '{relativePath}': {e.Message}");
}
}
private static object UpdateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
if (!File.Exists(fullPath))
{
return Response.Error(
$"Shader not found at '{relativePath}'. Use 'create' action to add a new shader."
);
}
if (string.IsNullOrEmpty(contents))
{
return Response.Error("Content is required for the 'update' action.");
}
try
{
File.WriteAllText(fullPath, contents);
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh();
return Response.Success(
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
new { path = relativePath }
);
}
catch (Exception e)
{
return Response.Error($"Failed to update shader '{relativePath}': {e.Message}");
}
}
private static object DeleteShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return Response.Error($"Shader not found at '{relativePath}'.");
}
try
{
// Delete the asset through Unity's AssetDatabase first
bool success = AssetDatabase.DeleteAsset(relativePath);
if (!success)
{
return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'");
}
// If the file still exists (rare case), try direct deletion
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully.");
}
catch (Exception e)
{
return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}");
}
}
//This is a CGProgram template
//TODO: making a HLSL template as well?
private static string GenerateDefaultShaderContent(string name)
{
return @"Shader """ + name + @"""
{
Properties
{
_MainTex (""Texture"", 2D) = ""white"" {}
}
SubShader
{
Tags { ""RenderType""=""Opaque"" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include ""UnityCG.cginc""
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}";
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bcf4f1f3110494344b2af9324cf5c571
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -378,6 +378,7 @@ namespace UnityMcpBridge.Editor
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException(

View File

@ -61,7 +61,8 @@ def asset_creation_strategy() -> str:
"- `manage_scene`: Manages scenes.\\n"
"- `manage_gameobject`: Manages GameObjects in the scene.\\n"
"- `manage_script`: Manages C# script files.\\n"
"- `manage_asset`: Manages prefabs and assets.\\n\\n"
"- `manage_asset`: Manages prefabs and assets.\\n"
"- `manage_shader`: Manages shaders.\\n\\n"
"Tips:\\n"
"- Create prefabs for reusable GameObjects.\\n"
"- Always include a camera and main light in your scenes.\\n"

View File

@ -3,6 +3,7 @@ from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_tools
from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools
from .execute_menu_item import register_execute_menu_item_tools
@ -14,6 +15,7 @@ def register_all_tools(mcp):
register_manage_editor_tools(mcp)
register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp)
register_manage_shader_tools(mcp)
register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp)
print("Unity MCP Server tool registration complete.")

View File

@ -0,0 +1,67 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection
import os
import base64
def register_manage_shader_tools(mcp: FastMCP):
"""Register all shader script management tools with the MCP server."""
@mcp.tool()
def manage_shader(
ctx: Context,
action: str,
name: str,
path: str,
contents: str,
) -> Dict[str, Any]:
"""Manages shader scripts in Unity (create, read, update, delete).
Args:
action: Operation ('create', 'read', 'update', 'delete').
name: Shader name (no .cs extension).
path: Asset path (default: "Assets/").
contents: Shader code for 'create'/'update'.
Returns:
Dictionary with results ('success', 'message', 'data').
"""
try:
# Prepare parameters for Unity
params = {
"action": action,
"name": name,
"path": path,
}
# Base64 encode the contents if they exist to avoid JSON escaping issues
if contents is not None:
if action in ['create', 'update']:
# Encode content for safer transmission
params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8')
params["contentsEncoded"] = True
else:
params["contents"] = contents
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity
response = get_unity_connection().send_command("manage_shader", params)
# Process response from Unity
if response.get("success"):
# If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred.")}
except Exception as e:
# Handle Python-side errors (e.g., connection issues)
return {"success": False, "message": f"Python error managing shader: {str(e)}"}