Skip to content

Commit 6a47945

Browse files
authored
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"
2 parents ed98fee + 55f7c55 commit 6a47945

File tree

8 files changed

+430
-2
lines changed

8 files changed

+430
-2
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte
3535
* `manage_editor`: Controls and queries the editor's state and settings.
3636
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
3737
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
38+
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
3839
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
3940
* `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
4041
</details>
@@ -89,7 +90,9 @@ Unity MCP connects your tools using two components:
8990

9091
Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1.
9192

92-
**Option A: Auto-Configure (Recommended for Claude/Cursor)**
93+
<img width="609" alt="image" src="https://github.com/user-attachments/assets/cef3a639-4677-4fd8-84e7-2d82a04d55bb" />
94+
95+
**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)**
9396

9497
1. In Unity, go to `Window > Unity MCP`.
9598
2. Click `Auto Configure` on the IDE you uses.

UnityMcpBridge/Editor/Tools/CommandRegistry.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public static class CommandRegistry
2020
{ "HandleManageAsset", ManageAsset.HandleCommand },
2121
{ "HandleReadConsole", ReadConsole.HandleCommand },
2222
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
23+
{ "HandleManageShader", ManageShader.HandleCommand},
2324
};
2425

2526
/// <summary>
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
using Newtonsoft.Json.Linq;
6+
using UnityEditor;
7+
using UnityEngine;
8+
using UnityMcpBridge.Editor.Helpers;
9+
10+
namespace UnityMcpBridge.Editor.Tools
11+
{
12+
/// <summary>
13+
/// Handles CRUD operations for shader files within the Unity project.
14+
/// </summary>
15+
public static class ManageShader
16+
{
17+
/// <summary>
18+
/// Main handler for shader management actions.
19+
/// </summary>
20+
public static object HandleCommand(JObject @params)
21+
{
22+
// Extract parameters
23+
string action = @params["action"]?.ToString().ToLower();
24+
string name = @params["name"]?.ToString();
25+
string path = @params["path"]?.ToString(); // Relative to Assets/
26+
string contents = null;
27+
28+
// Check if we have base64 encoded contents
29+
bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false;
30+
if (contentsEncoded && @params["encodedContents"] != null)
31+
{
32+
try
33+
{
34+
contents = DecodeBase64(@params["encodedContents"].ToString());
35+
}
36+
catch (Exception e)
37+
{
38+
return Response.Error($"Failed to decode shader contents: {e.Message}");
39+
}
40+
}
41+
else
42+
{
43+
contents = @params["contents"]?.ToString();
44+
}
45+
46+
// Validate required parameters
47+
if (string.IsNullOrEmpty(action))
48+
{
49+
return Response.Error("Action parameter is required.");
50+
}
51+
if (string.IsNullOrEmpty(name))
52+
{
53+
return Response.Error("Name parameter is required.");
54+
}
55+
// Basic name validation (alphanumeric, underscores, cannot start with number)
56+
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
57+
{
58+
return Response.Error(
59+
$"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."
60+
);
61+
}
62+
63+
// Ensure path is relative to Assets/, removing any leading "Assets/"
64+
// Set default directory to "Shaders" if path is not provided
65+
string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null
66+
if (!string.IsNullOrEmpty(relativeDir))
67+
{
68+
relativeDir = relativeDir.Replace('\\', '/').Trim('/');
69+
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
70+
{
71+
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
72+
}
73+
}
74+
// Handle empty string case explicitly after processing
75+
if (string.IsNullOrEmpty(relativeDir))
76+
{
77+
relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/"
78+
}
79+
80+
// Construct paths
81+
string shaderFileName = $"{name}.shader";
82+
string fullPathDir = Path.Combine(Application.dataPath, relativeDir);
83+
string fullPath = Path.Combine(fullPathDir, shaderFileName);
84+
string relativePath = Path.Combine("Assets", relativeDir, shaderFileName)
85+
.Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes
86+
87+
// Ensure the target directory exists for create/update
88+
if (action == "create" || action == "update")
89+
{
90+
try
91+
{
92+
if (!Directory.Exists(fullPathDir))
93+
{
94+
Directory.CreateDirectory(fullPathDir);
95+
// Refresh AssetDatabase to recognize new folders
96+
AssetDatabase.Refresh();
97+
}
98+
}
99+
catch (Exception e)
100+
{
101+
return Response.Error(
102+
$"Could not create directory '{fullPathDir}': {e.Message}"
103+
);
104+
}
105+
}
106+
107+
// Route to specific action handlers
108+
switch (action)
109+
{
110+
case "create":
111+
return CreateShader(fullPath, relativePath, name, contents);
112+
case "read":
113+
return ReadShader(fullPath, relativePath);
114+
case "update":
115+
return UpdateShader(fullPath, relativePath, name, contents);
116+
case "delete":
117+
return DeleteShader(fullPath, relativePath);
118+
default:
119+
return Response.Error(
120+
$"Unknown action: '{action}'. Valid actions are: create, read, update, delete."
121+
);
122+
}
123+
}
124+
125+
/// <summary>
126+
/// Decode base64 string to normal text
127+
/// </summary>
128+
private static string DecodeBase64(string encoded)
129+
{
130+
byte[] data = Convert.FromBase64String(encoded);
131+
return System.Text.Encoding.UTF8.GetString(data);
132+
}
133+
134+
/// <summary>
135+
/// Encode text to base64 string
136+
/// </summary>
137+
private static string EncodeBase64(string text)
138+
{
139+
byte[] data = System.Text.Encoding.UTF8.GetBytes(text);
140+
return Convert.ToBase64String(data);
141+
}
142+
143+
private static object CreateShader(
144+
string fullPath,
145+
string relativePath,
146+
string name,
147+
string contents
148+
)
149+
{
150+
// Check if shader already exists
151+
if (File.Exists(fullPath))
152+
{
153+
return Response.Error(
154+
$"Shader already exists at '{relativePath}'. Use 'update' action to modify."
155+
);
156+
}
157+
158+
// Add validation for shader name conflicts in Unity
159+
if (Shader.Find(name) != null)
160+
{
161+
return Response.Error(
162+
$"A shader with name '{name}' already exists in the project. Choose a different name."
163+
);
164+
}
165+
166+
// Generate default content if none provided
167+
if (string.IsNullOrEmpty(contents))
168+
{
169+
contents = GenerateDefaultShaderContent(name);
170+
}
171+
172+
try
173+
{
174+
File.WriteAllText(fullPath, contents);
175+
AssetDatabase.ImportAsset(relativePath);
176+
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader
177+
return Response.Success(
178+
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
179+
new { path = relativePath }
180+
);
181+
}
182+
catch (Exception e)
183+
{
184+
return Response.Error($"Failed to create shader '{relativePath}': {e.Message}");
185+
}
186+
}
187+
188+
private static object ReadShader(string fullPath, string relativePath)
189+
{
190+
if (!File.Exists(fullPath))
191+
{
192+
return Response.Error($"Shader not found at '{relativePath}'.");
193+
}
194+
195+
try
196+
{
197+
string contents = File.ReadAllText(fullPath);
198+
199+
// Return both normal and encoded contents for larger files
200+
//TODO: Consider a threshold for large files
201+
bool isLarge = contents.Length > 10000; // If content is large, include encoded version
202+
var responseData = new
203+
{
204+
path = relativePath,
205+
contents = contents,
206+
// For large files, also include base64-encoded version
207+
encodedContents = isLarge ? EncodeBase64(contents) : null,
208+
contentsEncoded = isLarge,
209+
};
210+
211+
return Response.Success(
212+
$"Shader '{Path.GetFileName(relativePath)}' read successfully.",
213+
responseData
214+
);
215+
}
216+
catch (Exception e)
217+
{
218+
return Response.Error($"Failed to read shader '{relativePath}': {e.Message}");
219+
}
220+
}
221+
222+
private static object UpdateShader(
223+
string fullPath,
224+
string relativePath,
225+
string name,
226+
string contents
227+
)
228+
{
229+
if (!File.Exists(fullPath))
230+
{
231+
return Response.Error(
232+
$"Shader not found at '{relativePath}'. Use 'create' action to add a new shader."
233+
);
234+
}
235+
if (string.IsNullOrEmpty(contents))
236+
{
237+
return Response.Error("Content is required for the 'update' action.");
238+
}
239+
240+
try
241+
{
242+
File.WriteAllText(fullPath, contents);
243+
AssetDatabase.ImportAsset(relativePath);
244+
AssetDatabase.Refresh();
245+
return Response.Success(
246+
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
247+
new { path = relativePath }
248+
);
249+
}
250+
catch (Exception e)
251+
{
252+
return Response.Error($"Failed to update shader '{relativePath}': {e.Message}");
253+
}
254+
}
255+
256+
private static object DeleteShader(string fullPath, string relativePath)
257+
{
258+
if (!File.Exists(fullPath))
259+
{
260+
return Response.Error($"Shader not found at '{relativePath}'.");
261+
}
262+
263+
try
264+
{
265+
// Delete the asset through Unity's AssetDatabase first
266+
bool success = AssetDatabase.DeleteAsset(relativePath);
267+
if (!success)
268+
{
269+
return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'");
270+
}
271+
272+
// If the file still exists (rare case), try direct deletion
273+
if (File.Exists(fullPath))
274+
{
275+
File.Delete(fullPath);
276+
}
277+
278+
return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully.");
279+
}
280+
catch (Exception e)
281+
{
282+
return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}");
283+
}
284+
}
285+
286+
//This is a CGProgram template
287+
//TODO: making a HLSL template as well?
288+
private static string GenerateDefaultShaderContent(string name)
289+
{
290+
return @"Shader """ + name + @"""
291+
{
292+
Properties
293+
{
294+
_MainTex (""Texture"", 2D) = ""white"" {}
295+
}
296+
SubShader
297+
{
298+
Tags { ""RenderType""=""Opaque"" }
299+
LOD 100
300+
301+
Pass
302+
{
303+
CGPROGRAM
304+
#pragma vertex vert
305+
#pragma fragment frag
306+
#include ""UnityCG.cginc""
307+
308+
struct appdata
309+
{
310+
float4 vertex : POSITION;
311+
float2 uv : TEXCOORD0;
312+
};
313+
314+
struct v2f
315+
{
316+
float2 uv : TEXCOORD0;
317+
float4 vertex : SV_POSITION;
318+
};
319+
320+
sampler2D _MainTex;
321+
float4 _MainTex_ST;
322+
323+
v2f vert (appdata v)
324+
{
325+
v2f o;
326+
o.vertex = UnityObjectToClipPos(v.vertex);
327+
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
328+
return o;
329+
}
330+
331+
fixed4 frag (v2f i) : SV_Target
332+
{
333+
fixed4 col = tex2D(_MainTex, i.uv);
334+
return col;
335+
}
336+
ENDCG
337+
}
338+
}
339+
}";
340+
}
341+
}
342+
}

UnityMcpBridge/Editor/Tools/ManageShader.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

UnityMcpBridge/Editor/UnityMcpBridge.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ private static string ExecuteCommand(Command command)
378378
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
379379
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
380380
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
381+
"manage_shader" => ManageShader.HandleCommand(paramsObject),
381382
"read_console" => ReadConsole.HandleCommand(paramsObject),
382383
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
383384
_ => throw new ArgumentException(

0 commit comments

Comments
 (0)