跳转至

name: vrchat-unity-mcp description: VRChat Unity MCP connection via Cloudflare Tunnel — connection, protocol, and environment inspection


VRChat Unity MCP 连接与环境检查

架构

  • Windows: Unity Editor (2022.3.22f1) + mcp-for-unity 插件 → 本地 HTTP 端口 8188
  • 隧道: cloudflared tunnel --url http://localhost:8188 在 Windows 上运行
  • Linux: 通过 Cloudflare Tunnel URL 访问(URL 每次重启都变)
  • 本项目路径: E:/ALCOM/project/Cazalis

连接步骤

1. 测试隧道是否可达

curl -s -o /dev/null -w "%{http_code}" https://<tunnel-url>.trycloudflare.com/health
# 返回 200 = 通
# 返回 503/502 = 隧道断开或服务未启动
# 返回 404 = 服务在但路径不对

2. 初始化 MCP Session

MCP-over-HTTP 需要先 initialize 拿到 session ID,之后所有请求带 session header:

# 获取 session ID (从 response headers 中的 mcp-session-id)
curl -s -D - "https://<tunnel>.trycloudflare.com/mcp" \
  -X POST \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"hermes","version":"1.0"}}}' \
  | grep -i "mcp-session-id"

3. 设置活动实例

curl -s "https://<tunnel>.trycloudflare.com/mcp" \
  -X POST \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Session-Id: <session_id>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"set_active_instance","arguments":{"instance":"Cazalis@<hash>"}}}'

MCP 协议关键点

params":{"name":"set_active_instance","arguments":{"instance":"Cazalis@"}}}'

## MCP 协议关键点### Session 生命周期
- Session 有超时(约几十秒无活动即过期),每次需要重新 initialize
- 复杂检查流程中建议每个批次的请求都重新获取 session

### 资源读取 (resources/read)
**不要用 GET 请求资源** — 所有资源通过 `resources/read` 方法读取:
```json
{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"mcpforunity://instances"}}

可用资源列表(通过 resources/list 获取): - mcpforunity://instances — 列出 Unity 实例 - mcpforunity://project/info — 项目信息 - mcpforunity://editor/state — Editor 状态 - mcpforunity://custom-tools — 项目自定义工具 - mcpforunity://tool-groups — 工具组信息 - mcpforunity://project/layers / project/tags - mcpforunity://scene/cameras - mcpforunity://scene/gameobject/{id} — 单个 GameObject 详情 - mcpforunity://editor/windows — 打开的窗口 - mcpforunity://editor/selection — 当前选中 - mcpforunity://editor/state — 编译/Play Mode 状态 - mcpforunity://menu-items — 所有菜单项 - mcpforunity://rendering/stats - mcpforunity://pipeline/renderer-features

工具调用 (tools/call)

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"<tool_name>","arguments":{...}}}

返回值在 SSE data: 事件中。工具返回的 content[0].text 是 JSON string,需要二次 parse。

工具组管理

默认只启用 core 组。其他组(animation, docs, probuilder, vfx, ui, scripting_ext, testing)需要手动激活:

{"name":"manage_tools","arguments":{"action":"activate","group":"animation"}}

环境检查清单(存档用)

进行改模操作前应记录以下状态:

基础连接

  • MCP health endpoint 返回 200
  • 能获取到 Unity 实例列表
  • 能设置活动实例 存档用)

进行改模操作前应记录以下状态:

基础连接

  • MCP health endpoint 返回 200
  • 能获取到 Unity 实例列表
  • 能设置活动实例### Editor 状态
  • 项目路径、Unity 版本
  • 当前场景名称和路径
  • Play Mode 是否运行
  • 是否在编译中
  • Console 有无报错

场景结构

  • 根节点列表(层级)
  • 主模型的完整子节点树(名称、InstanceID、活跃状态、组件类型)
  • 备份模型的状态

资产清单

  • AnimatorController 数量和路径
  • VRCExpressionsMenu 数量和路径
  • VRCExpressionParameters 数量和路径
  • 溶解动画文件(ON/OFF 配对)
  • 内衣材质路径

已有功能

  • 已存在的 Toggle 功能(哪些能正常工作)
  • 已损坏的功能(记录在案)

SSE 响应解析

响应格式为 Server-Sent Events:

event: message
data: {...json...}

event: message
data: {...result...}
需要取最后一个 data: 块作为实际结果。中间可能有 notifications/message 事件。 }

event: message data: {...result...}

需要取最后一个 `data:` 块作为实际结果。中间可能有 `notifications/message` 事件。## Python MCP Client (SSE via requests)

When using `execute_code` (Python) to call MCP tools through Cloudflare Tunnel, `urllib` fails on second request (HTTP 406) because it can't handle SSE streaming. Use `requests` library instead:

```python
import requests, json

accept = "application/json, text/event-stream"  # MUST include BOTH types!
base = "https://<tunnel>.trycloudflare.com/mcp"

def init():
    h = {"Content-Type": "application/json", "Accept": accept}
    b = json.dumps({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2024-11-05","capabilities":{},
        "clientInfo":{"name":"hermes","version":"1.0"}}})
    r = requests.post(base, data=b, headers=h, stream=True, timeout=15)
    sid = r.headers.get('mcp-session-id')
    for _ in r.iter_lines(decode_unicode=True): pass  # consume SSE
    r.close()
    return sid

def mcp_call(sid, name, args, id=2):
    h = {"Content-Type":"application/json","Accept":accept,"MCP-Session-Id":sid}
    b = json.dumps({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}})
    r = requests.post(base, data=b, headers=h, stream=True, timeout=30)
    data_events = []
    for line in r.iter_lines(decode_unicode=True):
        if line and line.startswith('data:'):
            data_events.append(line[5:].strip())
    r.close()
    if data_events:
        return json.loads(data_events[-1])
    return {"error":"no data"}
() if data_events: return json.loads(data_events[-1]) return {"error":"no data"} ``Key:Acceptheader MUST be"application/json, text/event-stream"(both types). Session expires every ~30-60 seconds. MUST be"application/json, text/event-stream"(both types). Session expires every ~30-60 seconds.## Known Issues - Session 会超时,长流程需每次重新 initialize -manage_gameobject的 action 只支持 CRUD(create/modify/delete/duplicate/move_relative/look_at),不支持 get_components -manage_material的 action 不支持get_properties-manage_assetget_components只对 GameObject 有效,ScriptableObject 会报错 -find_in_fileget_sha只支持.cs` 脚本文件

Material 操作指南

关键参数名(必须用对,否则报错)

操作 工具 关键参数 示例值
复制材质 manage_asset action=duplicate path + destination "destination": "Assets/.../NewMat.mat"
设贴图属性 manage_material action=set_material_shader_property material_path, property, value "property": "_MainTex", "value": "Assets/.../tex.png"
设数值属性 同上 property, value "property": "_UseMatCap", "value": 1.0
赋材质到模型 manage_material action=assign_material_to_renderer material_path, target, search_method "target": "Cazalis zigai/Body", "search_method": "by_path"

注意事项

  • set_material_shader_propertyvalue 支持 string(贴图路径)、number(float/int)、bool
  • assign_material_to_renderertarget 接受 GameObject 路径、名称或实例ID,需配合 search_methodby_path/by_name/by_id
  • 贴图类型属性传值用项目相对路径Assets/.../file.png),不需要加 property_type
  • 所有材质属性名全以小写 _ 开头(如 _MainTex, _ShadowBorderMask
  • lilToon 的 MatCap 默认 _UseMatCap=0,需要先设为 1.0 才能显示 _MatCapTex

素材/资产研究技巧(MCP 远程查看素材用途)

当需要远程研究 Unity 项目中的素材(贴图、材质、prefab)但无法直接在本地打开时: 设为 1.0 才能显示 _MatCapTex

素材/资产研究技巧(MCP 远程查看素材用途)

当需要远程研究 Unity 项目中的素材(贴图、材质、prefab)但无法直接在本地打开时:### 关键限制 - MCP manage_asset 没有 list/search action — 只能通过 get_info 获取单个资产信息 - MCP 没有 Project 面板遍历工具 — 无法直接列出文件夹内容 - find_in_file 只支持 .cs 脚本 — 不能搜索素材路径

解决方法:C# execute_code + System.IO

使用 execute_code 通过 Roslyn 编译 C# 代码来遍历文件系统:

// 列出目录下所有文件(排除 .meta)
var appPath = UnityEngine.Application.dataPath;
var rootDir = System.IO.Path.GetDirectoryName(appPath);
var dir = System.IO.Path.Combine(appPath, "popopolibrary");
if (System.IO.Directory.Exists(dir)) {
    var files = System.IO.Directory.GetFiles(dir, "*.*", System.IO.SearchOption.AllDirectories);
    foreach (var f in files) {
        if (!f.EndsWith(".meta")) {
            var relPath = f.Substring(rootDir.Length + 1).Replace("\\\\", "/");
            // 处理 relPath
        }
    }
}

读取材质信息

// 加载材质
var path = "Assets/xxx/SomeMaterial.mat";
var mat = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Material>(path);

// 获取 shader 名称
var shaderName = mat.shader.name;

// 获取所有纹理贴图
var texNames = mat.GetTexturePropertyNames();
foreach (var tn in texNames) {
    var tex = mat.GetTexture(tn);
    if (tex != null) {
        var texPath = UnityEditor.AssetDatabase.GetAssetPath(tex);
        // texPath 是项目相对路径
    } else {
        // 贴图通道为 null
    }
}

// 读取 float/color 属性(如果有 using 限制,用完整类型名)
var floatVal = mat.GetFloat("_SomeFloat");
var colorVal = mat.GetColor("_SomeColor");
限制,用完整类型名) var floatVal = mat.GetFloat("_SomeFloat"); var colorVal = mat.GetColor("_SomeColor"); ```### 读取模型渲染器材质

// 找到指定 GameObject 的渲染器
var go = UnityEngine.GameObject.Find("Body");
var mr = go.GetComponent<UnityEngine.SkinnedMeshRenderer>();

// 遍历材质槽
for (int i = 0; i < mr.sharedMaterials.Length; i++) {
    var mat = mr.sharedMaterials[i];
    var matPath = UnityEditor.AssetDatabase.GetAssetPath(mat);
    // Slot i: matPath
}

// 或者遍历场景中所有渲染器
var allRenderers = UnityEngine.Object.FindObjectsOfType<UnityEngine.SkinnedMeshRenderer>();

常见素材研究场景

场景 方法
这个目录有哪些文件? System.IO.Directory.GetFiles()
这个材质用什么 Shader? mat.shader.name
这个材质用了哪些贴图? mat.GetTexturePropertyNames() + mat.GetTexture()
当前模型渲染器用了哪些材质? 遍历 SkinnedMeshRenderer.sharedMaterials
模型上有没有眼睛专用材质? 检查材质路径是否包含 "eye"/"Eye"/"目"
贴图的具体路径? AssetDatabase.GetAssetPath(tex)

注意事项

  • 不要用 using 语句 — execute_code 不支持,必须用完整类型名
  • 返回字符串用 return,长度不限
  • System.Text.StringBuilder 默认不可用,需用完整类型名或提前声明
  • GetTexturePropertyNames() 返回所有纹理属性名
  • lilToon 材质常用通道:_MainTex(主贴图),_Main2ndTex(第二层),_Main3rdTex(第三层),_MatCapTex(MatCap),_EmissionMap(自发光)
  • Hidden/lilToonOutline 是 lilToon 的 Outline 变体 dTex(第三层),_MatCapTex(MatCap),_EmissionMap`(自发光)
  • Hidden/lilToonOutline 是 lilToon 的 Outline 变体## CRITICAL: write_file OVERWRITES — NEVER use it to append The write_file tool always overwrites the entire file. If you need to append content to an existing file:
  • Read the file first with read_file()
  • Combine old + new content in your code
  • Write the combined content with write_file() — but this creates a NEW file with BOTH old and new content OR: use patch() for targeted edits between existing lines. OR: use terminal("cat >> file") for simple appends.

The write_file → overwrite mistake is extremely dangerous. 70KB+ files can be lost in an instant. Always read before write when appending. extremely dangerous. 70KB+ files can be lost in an instant. Always read before write when appending.### VRCExpressionsMenu 参数链路分析

当需要远程检查 VRCExpressionsMenu 的 Toggle 控件是否正确连接到 Animator Controller 时:

1. 找到 Menu Asset 不要用中文路径硬编码(会导致 execute_code 返回空字符串),用 FindAssets 按名字搜索:

var guids = UnityEditor.AssetDatabase.FindAssets("ClothType_未分类 t:VRCExpressionsMenu");
if(guids.Length==0) return "not found";
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
var menu = UnityEditor.AssetDatabase.LoadAssetAtPath<VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionsMenu>(path);

2. 读取控件列表

// 基础信息 — 安全(无中文输出)
var sb = new System.Text.StringBuilder();
foreach(var c in menu.controls) {
    sb.AppendLine("type="+c.type+" name="+c.name+" param="+(c.parameter?.name??"null"));
}
return sb.ToString();

3. 获取 Animator Controller

var desc = UnityEngine.Object.FindObjectOfType<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
var ac = desc.gameObject.GetComponent<UnityEngine.Animator>().runtimeAnimatorController
         as UnityEditor.Animations.AnimatorController;
// ac.layers, ac.parameters 可用

4. 检查 AnyState 过渡条件(常用模式)

var layer = ac.layers[23]; // WardrobeCloth 层
var sm = layer.stateMachine;
foreach(var t in sm.anyStateTransitions) {
    sb.AppendLine(t.destinationState.name);
    foreach(var c in t.conditions)
        sb.AppendLine("  "+c.parameter+" "+c.mode+" "+c.threshold);
}
ach(var c in t.conditions) sb.AppendLine(" "+c.parameter+" "+c.mode+" "+c.threshold); } ```5. 检查溶解层过渡 Toggle 参数通常在溶解层(Layer 19-22 区间)控制 ON/OFF 状态切换:
for(int i=19; i<24; i++) {
    var layer = ac.layers[i];
    var sm = layer.stateMachine;
    foreach(var s in sm.states) {
        foreach(var t in s.state.transitions) {
            if(t.conditions!=null)
                foreach(var c in t.conditions) { /* c.parameter, c.mode, c.threshold */ }
        }
    }
}

常见链路模式:

VRCExpressionsMenu Control (Toggle type=102)
    → parameter.name (必须设置,否则是空壳按钮)
    → AnimatorController 参数 (Bool/Float/Int)
    → Layer AnyState 过渡条件 (WardrobeCloth 层,按值切换状态)
    → Layer 溶解层 (用条件 On/Off 切换溶解动画)

⚠️ Toggle vs Int 参数不匹配: Toggle 控件只能发送 0/1。如果它绑定的参数是 Int(期望多级值如 0~4),Toggle 实际只会设值为 1,达不到预期的多级效果。这种情况下需要把 Control type 改为 Radial Puppet 或使用 SubMenu + 多个 Button。 级值如 0~4),Toggle 实际只会设值为 1,达不到预期的多级效果。这种情况下需要把 Control type 改为 Radial Puppet 或使用 SubMenu + 多个 Button。### Console 日志分析方法

当检查过程中需要读取 Unity Console:

curl -s -X POST "$BASE/mcp" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"read_console","arguments":{"count":50,"types":["error","warning","log"],"format":"plain"}},"id":101}'

常见值得关注的日志模式: - "must be marked as Legacy" — Animation 组件使用的 Clip 缺少 Legacy 标记,需要勾选 AnimationClip.legacy = true - "Loaded data for an avatar we do not own, clearing blueprint ID" — 加载了不属于你的 avatar,SDK 自动清空了 blueprint ID(非严重错误) - 任何 NullReferenceException — 需要排查 - lilToon 相关的警告 — 材质配置问题

execute_code C# 操作指南

execute_code 使用 Roslyn C# 编译器,代码必须 return 一个 string。

⚠️ 关键认知:execute_code 能做的不只是"查"

这是我之前犯过的错误 — 以为 execute_code 只能查东西(读材质、看状态机),但实际上它可以执行任意 Unity Editor API 调用,包括修改 AnimatorController、创建 Layer、设置状态和过渡条件。它和写 Editor 脚本是完全一样的能力。

不要因为它叫"execute_code"就以为它功能受限 — Roslyn C# 编译器可以调所有 Unity Editor 命名空间,包括: - UnityEditor.Animations.AnimatorController - UnityEditor.Animations.AnimatorControllerLayer - UnityEditor.Animations.AnimatorState - UnityEditor.Animations.AnimatorStateTransition - UnityEditor.Animations.BlendTree - UnityEditor.Animations.ChildAnimatorState

遇到需要批量操作 Animator 的任务(创建 Layer、搭 4 状态循环、设过渡条件),直接写 C# 脚本用 execute_code 跑,不要想着"转成 MCP 命令"。 tate`

遇到需要批量操作 Animator 的任务(创建 Layer、搭 4 状态循环、设过渡条件),直接写 C# 脚本用 execute_code 跑,不要想着"转成 MCP 命令"。### 关键技巧 - C# 字符串转义:在 JSON 中 C# 字符串内的双引号必须转义为 \\\" - 不要用 using 语句(不允许),直接用完整类型名 - UnityEditor.Animations 命名空间可能不完整AnimatorControllerParameterType 需要使用 UnityEngine.AnimatorControllerParameter + AddParameter() 方法 - 读取 VRCExpressionsMenu:用 Resources.FindObjectsOfTypeAll<VRCExpressionsMenu>().FirstOrDefault(x=>x.name==\"MenuName\") - 读取 VRCExpressionParameters:同上模式 - 读取 AnimatorController:用 AssetDatabase.LoadAssetAtPath<AnimatorController>(\"path\") - 修改 AnimationClipnew AnimationClip()SetCurve(path, typeof(GameObject), \"m_IsActive\", curve)AssetDatabase.CreateAsset(clip, path) SetCurve(path, typeof(GameObject), \"m_IsActive\", curve)AssetDatabase.CreateAsset(clip, path)`### AnimatorController 修改模板

// 加载 Controller
var path = "Assets/Cazalis/Animation/FX/Cazalis_FX_Modified.controller";
var ctrl = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEditor.Animations.AnimatorController>(path);

// 添加 Int 参数
ctrl.AddParameter("Clothes", UnityEngine.AnimatorControllerParameterType.Int);

// 创建新 Layer
var layer = new UnityEditor.Animations.AnimatorControllerLayer();
layer.name = "SetA_Transition";
layer.defaultWeight = 1f;
layer.blendingMode = UnityEditor.Animations.AnimatorLayerBlendingMode.Override;
// 创建 StateMachine
var sm = new UnityEditor.Animations.AnimatorStateMachine();
sm.name = "SetA_SM";
layer.stateMachine = sm;
ctrl.AddLayer(layer);

// 创建 4 个状态
var stateA = sm.AddState("A_Closed");
stateA.motion = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AnimationClip>("Assets/.../OFF.anim");
stateA.writeDefaultValues = false;

var stateB = sm.AddState("B_Appearing");
stateB.motion = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AnimationClip>("Assets/.../ON.anim");
stateB.writeDefaultValues = false;

var stateC = sm.AddState("C_Opened");
stateC.motion = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AnimationClip>("Assets/.../ON.anim");
stateC.writeDefaultValues = false;

var stateD = sm.AddState("D_Disappearing");
stateD.motion = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AnimationClip>("Assets/.../OFF.anim");
stateD.writeDefaultValues = false;
oadAssetAtPath<UnityEngine.AnimationClip>("Assets/.../OFF.anim");
stateD.writeDefaultValues = false;// 设置过渡:A → B(Int == N)
var a2b = stateA.AddTransition(stateB);
a2b.hasExitTime = false;
a2b.hasFixedDuration = true;
a2b.duration = 1.0f; // 溶解时长
a2b.interruptionSource = UnityEditor.Animations.TransitionInterruptionSource.CurrentStateThenNextState; // ⚠️ 打断保护不可缺少
a2b.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 1f, "Clothes");

// 设置过渡:B → C(播完自动进 C)
var b2c = stateB.AddTransition(stateC);
b2c.hasExitTime = true;
b2c.exitTime = 1f;
b2c.hasFixedDuration = true;
b2c.duration = 0f;

// 设置过渡:C → D(Int != N)
var c2d = stateC.AddTransition(stateD);
c2d.hasExitTime = false;
c2d.hasFixedDuration = true;
c2d.duration = 1.0f;
c2d.interruptionSource = UnityEditor.Animations.TransitionInterruptionSource.CurrentStateThenNextState; // ⚠️ 打断保护不可缺少
c2d.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 1f, "Clothes");

// 设置过渡:D → A(播完自动回 A)
var d2a = stateD.AddTransition(stateA);
d2a.hasExitTime = true;
d2a.exitTime = 1f;
d2a.hasFixedDuration = true;
d2a.duration = 0f;

// 打断保护:B → A(Int != N)
var b2a_break = stateB.AddTransition(stateA);
b2a_break.hasExitTime = false;
b2a_break.hasFixedDuration = true;
b2a_break.duration = 0f;
b2a_break.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 1f, "Clothes");
 = 0f;
b2a_break.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 1f, "Clothes");// 打断保护:D → C(Int == N)
var d2c_break = stateD.AddTransition(stateC);
d2c_break.hasExitTime = false;
d2c_break.hasFixedDuration = true;
d2c_break.duration = 0f;
d2c_break.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 1f, "Clothes");

// 默认从 Entry 进入 A
var entry = sm.entryState;
var e2a = new UnityEditor.Animations.AnimatorTransition();
e2a.destinationState = stateA;
sm.AddEntryTransition(e2a);

// 保存
UnityEditor.EditorUtility.SetDirty(ctrl);
UnityEditor.AssetDatabase.SaveAssets();
UnityEditor.AssetDatabase.Refresh();
return "OK";
ctrl); UnityEditor.AssetDatabase.SaveAssets(); UnityEditor.AssetDatabase.Refresh(); return "OK"; ```### 常用 C# 操作速查

操作 代码
加载 AnimatorController AssetDatabase.LoadAssetAtPath<AnimatorController>(path)
添加参数 ctrl.AddParameter("name", AnimatorControllerParameterType.Int)
创建 Layer ctrl.AddLayer(new AnimatorControllerLayer{name="X"}) 或用 new AnimatorControllerLayer() 直接设属性
添加状态 sm.AddState("name")
添加过渡 stateA.AddTransition(stateB)
设条件 transition.AddCondition(AnimatorConditionMode.Equals, floatVal, "paramName")
加载 AnimationClip AssetDatabase.LoadAssetAtPath<AnimationClip>("path")
保存 EditorUtility.SetDirty(ctrl) + AssetDatabase.SaveAssets() + AssetDatabase.Refresh()
加载 VRCExpressionParameters AssetDatabase.LoadAssetAtPath<VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionParameters>(path)
加载 VRCExpressionsMenu AssetDatabase.LoadAssetAtPath<VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionsMenu>(path)
enu AssetDatabase.LoadAssetAtPath<VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionsMenu>(path)

当 VRChat 模型项目中存在中文或日文资源名时,C# execute_code 返回的字符串在 JSON 序列化/反序列化过程中会损坏,导致返回空字符串或解析失败。

症状:C# 代码运行成功(status=success),但 data.result 为空字符串。

根因:中/日文字符在 C# 字符串字面量中写入时被 JSON 二次编解码,或者在拼接 StringBuilder 时遇到含非ASCII字符的字符串后整个输出被截断。

解决方案

  1. 避免中文日文字符串字面量 — 不要写 "露肩短裙",用字节数组匹配:
// 匹配"露肩短裙"的UTF-8字节前缀 E9 9C B2
byte[] nb = System.Text.Encoding.UTF8.GetBytes(System.IO.Path.GetFileName(f));
bool isLujian = nb.Length >= 9 && nb[0]==0xE9 && nb[1]==0x9C && nb[2]==0xB2;
  1. 用索引替代中文名 — 在 AnimatorController 中通过参数索引/状态索引来引用,如 c.parameters[i].name 不会输出到 JSON 中:
// 用参数索引代替参数名
var paramMap = new Dictionary<string, int>();
for (int i = 0; i < c.parameters.Length; i++) paramMap[c.parameters[i].name] = i;
// 输出时只输出索引值
sb.Append("p").Append(pidx);
  1. System.IO.Directory.GetFiles 绝对路径 — 通过文件系统遍历文件,再用 AssetDatabase.LoadAssetAtPath 加载,避免在 C# 代码中使用中文字符串硬编码路径:
string fxDir = System.IO.Path.Combine(UnityEngine.Application.dataPath, "Cazalis", "Animation", "FX");
var files = System.IO.Directory.GetFiles(fxDir, "*.anim");
// 用文件名精确匹配,而不是路径中的中文
var fileDict = new Dictionary<string, string>();
foreach (var f in files) {
    if (!f.EndsWith(".meta")) fileDict[System.IO.Path.GetFileName(f)] = f;
}
  1. 输出时用短数字标识符 — 不用中文名/路径,用带分隔符的数字+简短ASCII字符串:

sb.Append("st").Append(i).Append("_crv=").Append(bindings.Length).Append("|");
// 而不是 sb.Append(l.name) 或 sb.Append(clip.name)
nd("_crv=").Append(bindings.Length).Append("|"); // 而不是 sb.Append(l.name) 或 sb.Append(clip.name) ``### 常见错误 | 错误 | 原因 | 修复 | |------|------|------| |not all code paths return a value| 代码没 return | 加return "result";| |AnimatorControllerParameterType找不到 | 命名空间引用问题 | 用UnityEngine.AnimatorControllerParameter+ctrl.AddParameter(param)| |List没有Length| VRC 菜单 Control 是 List | 用.Count不是.Length| |Keyframe(double,double)报错 | double→float 隐式转换 | 加 f 后缀:new Keyframe(0f, 1f)| |NullReferenceException| Resources.Find 返回 null | 先检查 null,或改用 AssetDatabase.LoadAssetAtPath | | **C# 返回空字符串**(status=success, result="") | 输出字符串含中文/日文字符,JSON 解析时损坏 | 用字节前缀匹配替代中文字符串字面量;用索引替代中文名输出;用 pipe(|)分隔、避免换行 | | **路径StartsWith全部失败**(看似明明对得上) |Path.Combine(root, "A/B")**不规范化分隔符**,会保留/;而Directory.GetFiles()返回的子路径用`,混合后 StartsWith 字面不匹配 | 用多参数形式 Path.Combine(root, "A", "B"),再 .Replace('/', Path.DirectorySeparatorChar) 强制规范化 |

路径分隔符 bug(实战案例)

// ❌ 错(isoDir 含 / 残留)
var isoDir = System.IO.Path.Combine(root, "Assets/Cazalis_ZG_YH01_Assets");
foreach(var mf in System.IO.Directory.GetFiles(assetsRoot, "*.meta", ...)) {
    if(mf.StartsWith(isoDir)) { ... }  // 永远 False
}

// ✅ 对
var isoDir = System.IO.Path.Combine(root, "Assets", "Cazalis_ZG_YH01_Assets");
isoDir = isoDir.Replace('/', System.IO.Path.DirectorySeparatorChar);

症状:分类 dict 一个为 0、另一个全装;DRY-RUN 用 Directory.GetFiles(isoDir,...) 时正常(OS API 规范化了 isoDir),切到 GetFiles(assetsRoot,...) 全扫时崩盘。

DIAG 速查:分类失败时立刻打印 isoDir 字面值看是否混合 \ /。 规范化了 isoDir),切到 GetFiles(assetsRoot,...) 全扫时崩盘。

DIAG 速查:分类失败时立刻打印 isoDir 字面值看是否混合 \ /。### 模型结构须知(Cazalis 系列) - Body ≠ Body_Base!Body 是面部(Cazalis_Face + Cazalis_Face_Alpha),Body_Base 是身体主体(Cazalis_Body) - 两个模型都有的 22 个子节点结构完全一致(Armature, Body, Body_Base, Cloth_* 等) - Body 有 2 个材质槽(Slot 0: Face, Slot 1: Face_Alpha),Body_Base 只有 1 个 - Cloth_Under_Bra 原版 active=false(默认隐藏),Cazalis zigai 版本需要手动设为 active=false 才能与原版行为一致

故障排除

  • Not Found (404) 在 GET 请求 → 应该用 POST resources/read
  • Session not found → session 已过期,重新 initialize
  • No instances → Unity Editor 未启动 MCP Session
  • Bad Request: Missing session ID → 请求头没带 MCP-Session-Id
  • Not Acceptable → 缺少 Accept: application/json, text/event-stream header
  • Not Found 在 /mcpforunity://* → 这是资源 URI,不是 HTTP 路径,要用 MCP 协议调用