MCP로 Vue 런타임을 AI 에디터에 연결하기
AI 코딩 에이전트한테 버튼이 왜 disabled인지 물어보면 소스를 쭉 따라갑니다. 부모 컴포넌트 찾고, props 흐름 추적하고, store 코드 읽고. Vue DevTools 열면 5초인 걸 한참 돌아가더라고요.
MCP(Model Context Protocol)로 AI 에디터와 브라우저 Vue 인스턴스를 직접 연결하는 걸 만들었습니다.
구조
[AI Editor] ←stdio→ [MCP Server] ←HTTP→ [Bridge] ←WebSocket→ [Browser Client]
- MCP Server: AI 에디터와 JSON-RPC로 통신. tool 요청을 Bridge에 HTTP로 전달하는 thin proxy
- Bridge: 요청 ID 기반 correlation으로 WebSocket 위에 RPC 패턴 구현
- Client: 브라우저에서 Vue 내부 구조에 접근하는 스크립트. 여기가 핵심
Bridge에서 WebSocket을 RPC처럼 쓰기 위해 이런 패턴을 썼습니다:
function sendToBrowser(method: string, params?: Record<string, unknown>): Promise<unknown> {
const id = String(++requestId);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error("timeout"));
}, 10_000);
pendingRequests.set(id, { resolve, reject, timer });
browserSocket.send(JSON.stringify({ id, method, params }));
});
}
WebSocket은 fire-and-forget이 기본인데, pending Map + id 매칭으로 요청-응답 쌍을 만들었습니다.
브라우저에서 Vue 내부에 접근하기
Vue 3는 마운트된 DOM 요소에 __vue_app__을 붙입니다. 여기서 시작합니다.
const app = document.getElementById("app").__vue_app__;
const root = app._instance;
컴포넌트 트리 순회는 instance.subTree(렌더링된 VNode 트리)를 DFS로 탐색합니다. 놓치기 쉬운 게 Vue 3의 Block Tree 최적화:
function walkVNode(vnode) {
if (vnode.component) walk(vnode.component);
if (Array.isArray(vnode.children)) {
for (const child of vnode.children) walkVNode(child);
}
// 이걸 빠뜨리면 v-if/v-for 안의 컴포넌트를 못 찾음
if (vnode.dynamicChildren) {
for (const child of vnode.dynamicChildren) walkVNode(child);
}
}
dynamicChildren은 Vue 3 컴파일러가 정적 노드를 건너뛰기 위해 동적 노드만 별도로 모아놓는 배열입니다. 이걸 모르면 트리의 절반을 순회하지 못합니다.
setupState의 이중 접근 트릭
<script setup>의 상태는 instance.setupState Proxy 뒤에 있습니다. 이 Proxy를 통하면 ref가 자동 unwrap되는데, ref인지 computed인지 판별하려면 raw 객체가 필요합니다.
const setupState = instance.setupState; // Proxy (ref auto-unwrap)
const rawState = setupState.__v_raw || setupState; // raw (메타데이터 접근)
for (const key of Object.keys(rawState)) {
const raw = rawState[key];
if (raw && raw.__v_isRef && raw.effect) {
// computed — ref이면서 effect가 있음
result[key] = { type: "computed", value: setupState[key] };
} else if (raw && raw.__v_isRef) {
result[key] = { type: "ref", value: setupState[key] };
}
}
rawState로 타입을 판별하고, setupState[key]로 실제 값을 가져옵니다. 두 경로를 조합하는 게 핵심입니다.
Vue 3.5에서 깨진 dependency graph
Vue 3.5에서 effect.deps가 배열에서 linked list로 바뀌었습니다. dependency graph 코드가 바로 깨졌거든요.
// Vue 3.4 이하: 배열
effect.deps.forEach(dep => { ... })
// Vue 3.5+: linked list
let link = effect.deps;
while (link) {
link = link.nextDep;
}
런타임에 두 버전을 분기합니다:
for (const effect of scope.effects) {
let dep = effect.deps;
if (Array.isArray(dep)) {
for (const d of dep) {
if (d === raw?.dep) { subscribers.push(effect); break; }
}
} else {
while (dep) {
if (dep.dep === raw?.dep) { subscribers.push(effect); break; }
dep = dep.nextDep;
}
}
}
공식 문서에 없는 내부 API를 쓰는 대가입니다.
Module Federation 환경의 문제
CDN remote 컴포넌트의 state 조회 불가
CDN으로 배포된 리모트 컴포넌트는 프로덕션 빌드라 __hmrId가 없고, setupState proxy가 빈 객체를 반환합니다. 해결은 못 하고 대응을 했습니다:
const source = instance.type?.__hmrId ? "local" : "cdn";
if (Object.keys(state).length === 0 && source === "cdn") {
result.hint = "CDN remote 컴포넌트 — 해당 모듈을 로컬 serve하면 조회 가능";
}
tool description에도 명시해서 AI가 source=cdn일 때 재시도하지 않도록 했습니다.
Cross-origin CSS 토큰
각 모듈의 dev server가 다른 포트라서 cssRules 접근 시 SecurityError가 납니다. 디자인 토큰 조회가 막히는 거죠.
// 1차: same-origin만 cssRules 접근
for (const sheet of document.styleSheets) {
try { rules = sheet.cssRules; } catch { continue; }
}
// 2차: fetch로 stylesheet 텍스트를 직접 가져와서 regex 파싱
if (Object.keys(tokens).length === 0) {
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
const res = await fetch(link.href);
const text = await res.text();
const rootMatch = text.match(/:root\s*\{([^}]+)\}/g);
}
}
dev server가 Access-Control-Allow-Origin: *를 보내니까 fetch가 됩니다. 우아하진 않지만 동작합니다.
client.ts 주입
rsbuild 플러그인이 serve 시 client를 트랜스파일해서 주입합니다:
export function pluginVueMcp(): RsbuildPlugin {
return {
name: "vue-mcp-plugin",
setup(api) {
if (!isDevServer) return;
const transpile = () => {
const source = fs.readFileSync(clientPath, "utf-8");
const result = ts.transpileModule(source, {
compilerOptions: { target: ScriptTarget.ES2020, module: ModuleKind.None },
});
fs.writeFileSync(outputPath, result.outputText);
};
transpile();
fs.watch(clientPath, () => transpile());
api.modifyRsbuildConfig((config) => {
config.html.tags.push({
tag: "script",
attrs: { src: "/vue-mcp-client.generated.js", defer: true },
});
});
},
};
}
ts.transpileModule 단일 호출에 module: None이라 IIFE 그대로 나갑니다. 빌드 파이프라인을 건드리지 않으면서 주입하는 가장 가벼운 방법이었습니다.
사용 예시
버튼이 왜 disabled인지:
→ vue_inspect_component(uid: "56")
← { props: { disabled: true }, state: { isLoading: false } }
store 현재 상태:
→ vue_store_state("auth")
← { user: { plan: "free", ... }, isAuthenticated: true }
enterprise plan일 때 UI 확인:
→ vue_patch_store("auth", { user: { plan: "enterprise" } })
소스에서 추론하는 것과 런타임 값을 직접 보는 건 정확도 차이가 큽니다. 상태가 여러 store와 computed를 거쳐 전파되는 앱에서 특히요.
마무리
Vue DevTools도 같은 내부 구조를 쓰고 있고, 깨지면 고치면 됩니다. MCP 프로토콜이 아직 초기라 에디터마다 지원이 다르긴 한데, 소스만 읽고 추론하는 것과 런타임 값을 보고 판단하는 것은 확실히 다르더라고요.