skia-studio-mcp
v0.2.1
Published
MCP stdio server that bridges an AI agent to a live Skia Studio browser tab for AGSL/SkSL shader authoring, uniform/graph editing, animation & binding control, and screenshot-based visual verification.
Maintainers
Readme
skia-studio-mcp
MCP stdio server,把一个 Agent(Claude Code 等 MCP client)桥接到一个正在运行的 Skia Studio
浏览器 tab。最初四个核心工具(read_project/update_shader_source/set_uniform/render_frame)
的语义、Origin 校验义务定死于 ../docs/team/briefs/F1-mcp-bridge.md;协议草案与 F0 实测数据见
../spike/mcp-bridge-poc/README.md。set_preview_node、GLSL→SkSL 移植 resource、服务端可配置
环境变量、图编辑五工具(create_node/update_node/connect_nodes/delete_node/
set_main_node)、以及动画/绑定/资产七工具(upsert_animation/delete_animation/
trigger_animation/upsert_binding/delete_binding/import_asset/remove_asset)是之后按
「MCP 发布前批次」追加的能力,语义以本 README 与 src/index.ts 里每个工具的 description 为准;
这十二个工具的权威校验都在网页侧 src/bridge/bridgeCommands.ts(见该文件顶部注释),本包的 zod
inputSchema 只把关形状。
Agent(Claude Code) ↔ stdio(MCP) ↔ 本包(skia-studio-mcp) ↔ ws://127.0.0.1:8787 ↔ 网页桥模块(../src/bridge/)这是独立于主 app 的顶层包:自己的 package.json / pnpm-lock.yaml / node_modules,不进根
pnpm-workspace(仓库根本来就没有 workspace 文件),pnpm install 只影响这个目录,不碰根
lockfile。当前未 npm publish(package.json 里 "private": true 是有意的安全网,防止在
决定发布前被误发);npm pack 产物已验证可安装可运行(见下)。
快速开始
1. 打开 Skia Studio 网页,保持这个 tab 开着。 桥连接靠网页里的 src/bridge/bridgeClient.ts
主动连过来(ws://127.0.0.1:8787)——本地开发 http://localhost:5185(pnpm dev 起的端口),
或生产 https://skia.xiaolin.work/studio/。这个 tab 必须留在前台标签页里(不用切到 Editor/
Preview 视图也行,但 render_frame 需要三区视图才有活的渲染器,见下)。同一时刻只服务一个
浏览器 tab:多开一个新 tab 会顶替旧 tab 的连接(旧 tab 的"Agent connected"徽标会变灰并停止
自动重连,不会跟新 tab 抢)。
2. 安装并构建本包:
cd mcp-server
pnpm install
pnpm build # 先跑 scripts/check-protocol-parity.mjs 校验协议一致,再 tsc -p tsconfig.json → dist/3. 接入 Claude Code(.mcp.json)。 在仓库根(或任意项目目录)添加 .mcp.json:
npm 包(已发布 [email protected],零克隆零构建,推荐):
{
"mcpServers": {
"skia-studio": {
"command": "npx",
"args": ["-y", "skia-studio-mcp"]
}
}
}等价的一行命令:claude mcp add skia-studio -- npx -y skia-studio-mcp。
开发本仓库时用本地构建产物(免 npx 拉包,改完 pnpm build 即生效):
{
"mcpServers": {
"skia-studio": {
"command": "node",
"args": ["/绝对路径/skia-studio/mcp-server/dist/index.js"]
}
}
}不想预构建,也可以直接用 tsx 跑源码(省一步 pnpm build,多一次 tsx 启动开销):
{
"mcpServers": {
"skia-studio": {
"command": "npx",
"args": ["-y", "tsx", "/绝对路径/skia-studio/mcp-server/src/index.ts"]
}
}
}配好后重启 Claude Code(或重新加载 MCP 配置),打开网页,左下角状态栏应该在几秒内亮起 "Agent connected" 徽标——这条连接是网页主动连过去的,跟哪个先起没关系:Agent 侧先起、网页 还没连上时,工具调用会先原地等最多 8s(见下方"已知取舍")再判定"没连",覆盖网页正在退避 重连的窗口。
本机代理注意事项(重要,踩过坑:见 ADR-16)
如果你本机开着 Surge / Clash / Charles 之类的代理软件,默认配置可能会把
ws://127.0.0.1:8787 这条本地回环连接也劫持走,导致网页连不上桥(或连上了但握手异常)。
ADR-16 的 F0 spike 实测踩过这个坑:本机代理会拦 localhost。排查方法:
- 代理软件里把
127.0.0.1/localhost加进"直连 / 不代理"名单(Surge 叫 Bypass List / 跳过代理规则,其它代理软件术语类似)。 - 或者临时关掉代理软件的"增强模式 / 系统代理"再试一次,确认是不是代理导致的,再回去精确配置 跳过规则。
- 症状:网页控制台没有明显报错(WS 失败是网页桥的一等状态,静默重试,不刷屏),但徽标一直
是灰的、
read_project等工具一直返回"No browser tab connected"——先怀疑代理,再怀疑 server 没起。
服务端可配置(环境变量)
启动 node dist/index.js(或 pnpm start)前可以设三个可选环境变量,都有默认值,不设就是
之前的行为:
| 变量 | 作用 | 默认值 |
|---|---|---|
| SKIA_STUDIO_BRIDGE_PORT | 覆盖桥监听端口 | 8787 |
| SKIA_STUDIO_EXTRA_ORIGINS | 逗号分隔的 hostname 列表,追加进 Origin 白名单(不是替换:skia.xiaolin.work/localhost/127.0.0.1 这三个默认值永远有效) | 空 |
| SKIA_STUDIO_APP_URL | 覆盖"没有浏览器 tab 连接"提示文案里引导打开的 URL | https://skia.xiaolin.work/studio/ |
SKIA_STUDIO_BRIDGE_PORT 传了非法值(非 1-65535 整数)时会在 stderr 打警告并回退默认端口,
不会让进程崩溃。
改端口要两边配对:SKIA_STUDIO_BRIDGE_PORT 只改了 server 这一侧监听的端口;网页侧要用
?bridgePort= URL query 参数指到同一个端口(见 src/bridge/bridgeClient.ts 的
resolvePort),例如 server 用 SKIA_STUDIO_BRIDGE_PORT=9911 node dist/index.js 起在 9911,
网页就要打开 http://localhost:5185/?bridgePort=9911(或生产域名同理拼上这个 query)——两边
互不感知对方,改一边忘了改另一边,症状就是网页连不上桥(跟"本机代理劫持"长得很像,先看端口
是不是配对了再去查代理)。
SKIA_STUDIO_BRIDGE_PORT=9911 SKIA_STUDIO_EXTRA_ORIGINS=my-preview.example.com node dist/index.js开发隔离(默认行为,仅开发者关心,线上用户无感知):本地 pnpm dev 的页面默认连
8788(BRIDGE_DEV_DEFAULT_PORT,见 src/bridge/bridgeProtocol.ts),生产构建默认 8787——
dev tab 与生产 tab 天然各连各的桥,不互相顶替,agent 不会静默连错工程(read_project 回包的
connectedTab.url 让 agent 说出自己连的是谁,页面紫色指示器让人一眼看到桥在哪个 tab)。
开发本仓库时在 .mcp.json 注册两个 server 即可同时驱动两边:
{
"mcpServers": {
"skia-studio": { "command": "npx", "args": ["-y", "skia-studio-mcp"] },
"skia-studio-dev": { "command": "npx", "args": ["-y", "skia-studio-mcp"],
"env": { "SKIA_STUDIO_BRIDGE_PORT": "8788" } }
}
}?bridgePort= URL 参数仍可覆盖两种构建的默认端口(比如让 dev 页面临时连生产桥)。
十七个工具
| 工具 | 何时用 | 关键参数 |
|---|---|---|
| read_project | 改之前先看工程当前形状;改完确认结果 | 无。project.assets[] 已摘要化,见下 |
| update_shader_source | 改一个 runtimeShader 节点的 AGSL/SkSL 源码,拿回真实编译诊断 | nodeId, code(完整替换,非 diff)。compileStatus: 'error' 时若诊断像典型 GLSL 写法,回包会带一个 hint 字段,见下「Resource」 |
| set_uniform | 调一个 uniform 的当前值 | nodeId, uniformName, value: number[] |
| render_frame | 截图看效果——以 MCP image content 返回,Claude 等多模态 Agent 直接"看到"图;配一段 JSON 文本(byteLength/width/height/nodeId) | nodeId?(省略=主输出), restorePreview?(默认 false;true 时截图后把预览切回调用前的节点,见下) |
| set_preview_node | 单独切一次预览目标(不截图)——配合 render_frame 使用:先切过去让人看,再省略 nodeId 切回主输出 | nodeId?(省略=切回主输出) |
| create_node | 往图里新建一个节点,复用 UI「New」菜单同一套默认值 | type(11 种节点类型之一), name?, props?(该类型的初始属性覆盖,字段与 update_node 的 patch 同一套) |
| update_node | 改一个已存在节点自身的标量/枚举属性(不是连线、不是源码、不是 uniform 值) | nodeId, patch(按节点类型有各自的合法字段表,见 src/index.ts 里该工具的 description) |
| connect_nodes | 把一个节点的输出接进另一个节点的输入槽,或断开 | consumerNodeId, slot(按 consumer 类型:runtimeShader 用 childSlot 的 uniformName;shaderPass/blur/dropShadow 固定 'input';renderPass 固定 'shader';feedback 固定 'source'), producerNodeId(null = 断开) |
| delete_node | 删除一个节点 | nodeId, disconnectReferences?(默认 false:若节点仍被引用则拒绝并列出全部引用方;true 则先断开/清理全部引用再删) |
| set_main_node | 切换 graph.mainNodeId(渲染输出节点) | nodeId |
| upsert_animation | 新建或整体替换一个事件触发 Animation | animation(整对象;id 省略=新建,给出且存在=整体替换,给出但不存在=报错) |
| delete_animation | 删除一个 Animation | animationId |
| trigger_animation | 手动发射一个 Animation 自测效果(配合 render_frame) | animationId(disabled 或所有 track 目标都被 binding 占用会明确报错) |
| upsert_binding | 新建或整体替换一个 Binding(input/control 驱动 uniform 或 nodeProp) | binding(整对象,同 upsert_animation 的 upsert 语义;source.type 为 'animation'/'expression' 会被拒绝,见下) |
| delete_binding | 删除一个 Binding | bindingId |
| import_asset | 导入图片/视频/音频资产(只入库,不建节点) | filePath?(mcp-server 侧读,≤25MB)或 dataBase64?+mimeType(agent 生成的小资源,≤~2MB),二选一 |
| remove_asset | 删除一个资产 | assetId, disconnectReferences?(默认 false:被 imageShader/videoShader/audio 输入引用则拒绝并列出引用方) |
面向 Agent 的完整语义文案见 src/index.ts 里每个工具的 description(那是产品文案,不是这里
的摘要)。图编辑五工具(create_node/update_node/connect_nodes/delete_node/
set_main_node)与动画/绑定两个 upsert/delete 工具(upsert_animation/delete_animation/
upsert_binding/delete_binding)的每次 mutation 回包都可能带一个 verifierErrors 数组:只在该
操作之后工程整体出现 fatal/error 级诊断时才出现(如工程原本就有 fatal 级问题——比较少见的边缘
情况),不阻塞该次调用本身的成功返回,纯供 Agent 感知"工程整体状态是否仍然健康"。
upsert_binding 拒绝 source.type: 'animation' | 'expression'
BindingSource 的 schema 保留了 animation/expression 两个分支(见
docs/runtime-binding-model.md §2.1),但 Runtime v1 完全不求值它们——如果 upsert_binding 静默
接受这类 binding,agent 会以为它生效了,实际上这个 binding 永远不会驱动任何东西。所以这两个
source.type 会被明确拒绝,错误文案指向该文档;目前只支持 'input'(驱动的 input 需已存在于
project.inputs)与 'control'(controlId 需已存在于 project.controls)。
trigger_animation 与 disabled / binding 占用
trigger_animation 复用 AnimationPanel.tsx 里 Play 按钮同一条路径
(CanvasKitRenderer.triggerManualAnimation),语义是"让这个 animation 的触发事件现在发生",
不管它自己配置的 trigger.type 是什么。两种情况会被挡住并给出明确文案而不是静默 no-op:
animation 本身 enabled: false(先 upsert_animation 传 enabled: true);或者它全部 track 的
目标当前都被一个 enabled Binding 占用(docs/animation-model.md §2:binding 永远优先于
animation,需要先 delete_binding 或把 binding 设为禁用)。
import_asset 的两种入参分支
filePath 分支由 mcp-server 进程自己用 fs 读取绝对路径——文件字节从不进入 agent 的上下文,
按扩展名推断 mimeType(png/jpg/jpeg/webp/avif/gif 图片、mp4/m4v/webm/mov 视频、
mp3/wav/ogg/m4a/aac/flac 音频,见 docs/media-support-matrix.md),超过 25MB 拒绝并提示改走
网页 UI 的 Assets 面板。dataBase64 分支是给 agent 自己生成的小资源用的(比如程序化生成的贴图),
必须同时给 mimeType,解码后限制在 ~2MB(比 filePath 严格得多,因为这些字节确实经过了 agent 的
上下文)。两种分支最终都只把资产写入 project.assets,不自动建节点——把资产接到图里要另外调
create_node/update_node 设置 assetId。
read_project 的 assets[] 摘要化
project.assets[].dataUrl 是内联 base64,单个资源可达数 MB——原样返回会撑爆 Agent 的上下文。
read_project 在网页侧发送前就把每个 asset 的 dataUrl 换成摘要(src/bridge/bridgeCommands.ts
的 summarizeAsset),保留其余字段,加三个新字段:
{ hasData: boolean, approxByteLength?: number, mimeType?: string }approxByteLength 是从 base64 长度估算的字节数(约等于,不是精确值);hasData: false 表示该
asset 本来就没有 dataUrl(内置生成图,按 name 约定)。这个工具不会返回任何图片/视频/音频
二进制数据本身。
render_frame 的 restorePreview
render_frame 传 nodeId 且该节点不是 renderPass 类型时,会像人手动点击那个节点一样切换
预览目标(setPreviewNode),再截图——这是有意的副作用,人会在网页上实时看到预览切换(见
01-产品原则.md #2)。默认 restorePreview: false 保留这个行为(切完不恢复,人接着看这个
节点)。传 restorePreview: true 会在截图完成后把预览切回调用前的节点(人依然会看到一次
"切过去又切回来",只是最终画面回到了原状)。
set_preview_node
单独把预览切到某个节点、或切回主输出(省略 nodeId),不带截图。跟 render_frame 内部切换
预览用的是同一个 store action,人会实时看到切换。典型用法:render_frame 检查完某个节点的输出后,
调 set_preview_node(不传 nodeId)把预览交还给人类正在看的主输出。
Resource:GLSL→SkSL 移植指南
除了 17 个工具,本包还注册了两个只读 MCP resource,其一是 skia-studio://guides/porting-glsl-to-sksl
(另一个是 Agent 工作流指南,见下一节)。
内容就是仓库根 docs/porting-glsl-to-sksl.md——一份实测验证过的 GLSL(Shadertoy 风格)→AGSL/SkSL
移植规范(sampler2D vs uniform shader、texture()/texelFetch() vs shader.eval()、
fwidth/dFdx/dFdy 屏幕空间导数缺失、gl_FragCoord vs main(coord) 参数、mainImage vs
main、precision 限定符、#define/预处理器差异等)。构建期由 scripts/copy-assets.mjs 把该
文档拷进 mcp-server/assets/(跟 dist/ 平级,见 package.json 的 files 数组),运行时按
import.meta.url 相对路径读取;assets/ 目录缺失时 resource 读取会优雅降级(返回一段错误说明,
不会抛异常),不影响下面的 hint 机制。
update_shader_source 在 compileStatus 是 'error' 时,会检查真实编译诊断文本是否命中典型
GLSL 症状(症状表见 src/glslHints.ts 的 GLSL_SYMPTOMS,每条都摘自这份指南、附一句 SkSL
写法要点),命中就在返回 JSON 里附一个 hint 字段,指出这像 GLSL 习惯用法 + 对应 SkSL 写法要点
- 提示去读这个 resource。
Resource:Agent 工作流指南
第二个只读 MCP resource:skia-studio://guides/agent-workflow。内容是 guides/agent-workflow.md
(英文,源文件在本包 guides/ 目录下入库维护,assets/ 里的同名文件跟 porting guide 一样只是
构建产物)——面向 Agent 的实操指南:典型闭环(read_project → 改动 → render_frame)、按意图分组
的十七工具速查表、工程模型(图的边语义、uniform 取值在 binding/animation/currentValue 之间的优先级
真相)、正在被人实时观看这个 tab 时的礼仪、动画自测配方、资产导入建议、已知限制。构建期同样由
scripts/copy-assets.mjs 拷进 mcp-server/assets/,运行时读取与优雅降级模式跟 porting guide
完全一致。
发布到 MCP Registry
(发布前提:包已 npm publish 到 npm 公共 registry——MCP Registry 只托管元数据,不托管制品。
mcp-server/package.json 里 "private": true 是防误发安全网,npm publish 前需要人工摘除,
见下面「发布前检查清单」。)
- 安装
mcp-publisherCLI(brew install mcp-publisher,或从 releases 下载对应平台的预编译 二进制)。 - 确认
package.json的mcpName字段("io.github.lixiaolin94/skia-studio")与仓库根mcp-server/server.json的name字段完全一致——这是 registry 验证 npm 包归属的机制(读 npm 包package.json里的mcpName,必须等于server.json.name)。用 GitHub 认证发布时,name必须以io.github.<你的 GitHub 用户名>/开头。 - 登录:
mcp-publisher login github(弹出 GitHub Device Flow:打开https://github.com/login/device,输入终端里打出的一次性代码授权)。 - 发布:
cd mcp-server && mcp-publisher publish(默认读同目录下的server.json)。成功后可用curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.lixiaolin94/skia-studio"确认。 - 每次发新版本:
server.json的顶层version与packages[0].version都要跟 npm 上新发布的package.json版本号一致(三者必须同步),改完重新跑mcp-publisher publish。
依据:Publisher CLI 命令参考、 Quickstart: Publish an MCP Server、 Package Types(npm 归属校验)。
发布前检查清单(人工动作,本轮改动不会替你做)
许可证已确认(2026-07-14,笑林拍板):PolyForm Noncommercial 1.0.0——源码开放、 允许任何非商业用途(个人/教育/研究/非营利),不允许商业使用。
package.json的"license": "PolyForm-Noncommercial-1.0.0"(合法 SPDX 标识符)与仓库根 / 本目录的LICENSE.md(官方原文,取自 polyformproject/polyform-licenses)均已就位,npm pack已验证 LICENSE.md 随包分发。
清单执行状态(2026-07-14):
- [x] 摘除
"private": true(笑林执行)。 - [x]
npm publish --access public——[email protected]已发布,npx -y skia-studio-mcp线上冒烟通过(17 工具 + 2 resources 全注册,SIGTERM 干净退出)。 - [x] README 的"尚未发布"措辞已全部更新。
- [x] MCP Registry 注册完成(2026-07-14):
mcp-publisher login github && mcp-publisher publish, registry API 已可检索到io.github.lixiaolin94/[email protected]。发布清单就此全部完成。
独立启动(调试用,不经 Claude Code)
node dist/index.jsstderr 会打印监听地址与连接事件;stdout 保留给 MCP JSON-RPC 帧,不会有任何人类可读输出。
端到端契约冒烟:pnpm verify:mcp
仓库根有一条 pnpm verify:mcp(scripts/verify-mcp.mjs),一条命令跑通「真实 vite dev server +
真实本包 stdio 子进程 + 真实 @modelcontextprotocol/sdk Client + headless 系统 Chrome
(playwright-core,channel:'chrome',不是 Playwright 自带 Chromium)」的完整链路,逐个调用全部
17 个工具 + 读 2 个 resource,断言契约行为(工具/annotations 注册、GLSL hint、图编辑/动画/绑定/
资产的合法与非法路径、render_frame 截图等)。
什么时候跑:改了 Project Schema(src/core/projectSchema.ts)、src/bridge/**
(bridgeCommands.ts/bridgeProtocol.ts)、或任何被 bridgeCommands.ts 调用的 store action
(src/store/studioStore.ts)之后——这些改动不会碰本包的 TypeScript 类型检查,只有真实跑一遍
17 个工具才能知道 Agent 实际感知到的行为有没有被破坏。
前置:本包必须已构建(pnpm build,产出 dist/index.js);本机需要一份真实 Google Chrome。
脚本自己起独立的 vite/桥端口(不用默认的 5173/8787),跟你正开着的真实 dev server / mcp-server
互不干扰,可以直接在开发中跑,不用先关掉手头的服务。
cd .. && pnpm verify:mcp # 从仓库根跑;或直接 cd mcp-server && pnpm build 之后回根目录跑已知取舍 / 限制
- 不做 token 鉴权:仅本机回环(
127.0.0.1)+ Origin 白名单(默认skia.xiaolin.work/localhost/127.0.0.1,可用SKIA_STUDIO_EXTRA_ORIGINS追加)。与 ADR-16 一致,MVP 阶段的 显式取舍。 - 无 Origin 头的连接放行:真实浏览器跨源 WS 握手总带 Origin 头,白名单挡的是"恶意网页"这个
攻击面;非浏览器客户端(测试脚本等)可能不带 Origin 头,这里选择放行而不是拒绝——安全边界仍然是
"只绑 127.0.0.1"。见
src/wsBridge.ts顶部注释。 render_frame没有尺寸参数:早期版本声明过width/height但实现一直忽略它们(陷阱), 已经从inputSchema里彻底删掉。既有的screenshot()/renderPassScreenshot()截图能力不 接受尺寸参数,输出永远是网页当前 canvas 尺寸(project.preview.canvas,见read_project)。- 同一时刻只服务一个浏览器 tab 连接:新连接会顶掉旧连接(多开 tab / 刷新页面时符合直觉)。 旧 tab 收到顶替通知(自定义 WS close code)后会停止自动重连并把徽标置灰,不会跟新 tab 轮流互踢——但也意味着旧 tab 从此不再是桥的连接方,除非你手动刷新那个 tab。
- 工具调用到达但网页还没连上时,先等最多 8s(每 500ms 轮询一次) 再返回 "No browser tab connected" 指引,覆盖网页正在指数退避重连(1s 起步、上限 10s)的窗口;8s 仍没连上才真的判定为"没连"。
- 协议类型物理上仍是两份:
src/protocol.ts(本包)与../src/bridge/bridgeProtocol.ts(主 app)各自维护一份桥接 wire 格式类型,没有共享 npm 包(评估过抽共享包 / 跨包相对路径 import,两条路都要动 tsconfig(rootDir) 或引入 monorepo 工具,成本超过收益)。改用零依赖的 构建期一致性校验:pnpm build/pnpm typecheck前会先跑scripts/check-protocol-parity.mjs,两侧标了shared-wire-protocol区块的文本必须逐字节 相同、桥端口常量数值必须相同,任一不一致就让构建失败。 - npm 与 MCP Registry 均已发布:
[email protected](2026-07-14,许可证 PolyForm Noncommercial 1.0.0,不允许商用),npx -y skia-studio-mcp线上冒烟通过; 官方 Registry 记录io.github.lixiaolin94/skia-studio(registry API 可检索)。 - Safari / Firefox 未测:F0/F1/F2 的端到端实测都在 Chrome(Playwright
channel: 'chrome') 上做的;ADR-16 提到的"PNA 政策可能变化"约束尚未在其它浏览器复核。
