Skip to content

Commit 2f89a8d

Browse files
committed
feat: add MCP Apps protocol support to visualizer
Implement JSON-RPC postMessage bridge so the visualizer works as an interactive app inside Claude Desktop, VS Code, and other MCP Apps hosts.
1 parent 5abcf71 commit 2f89a8d

1 file changed

Lines changed: 118 additions & 17 deletions

File tree

mcp/ui/visualizer.html

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,85 @@ <h1>Statekit Visualizer</h1>
143143
<script>
144144
const { createApp, ref, computed, watch, onMounted, nextTick } = Vue;
145145

146+
// --- MCP App Bridge ---
147+
const _mcpPending = {};
148+
let _mcpNextId = 1;
149+
let _mcpMachineId = null;
150+
let _mcpAppMode = false;
151+
152+
function _mcpSend(msg) {
153+
if (window.parent && window.parent !== window) {
154+
window.parent.postMessage(msg, '*');
155+
}
156+
}
157+
158+
function callServerTool(name, args) {
159+
const id = _mcpNextId++;
160+
const msg = { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } };
161+
return new Promise((resolve, reject) => {
162+
_mcpPending[id] = { resolve, reject };
163+
_mcpSend(msg);
164+
});
165+
}
166+
167+
window.addEventListener('message', (ev) => {
168+
const msg = ev.data;
169+
if (!msg || !msg.jsonrpc) return;
170+
171+
// JSON-RPC response (has id, no method)
172+
if (msg.id != null && !msg.method) {
173+
const pending = _mcpPending[msg.id];
174+
if (pending) {
175+
delete _mcpPending[msg.id];
176+
if (msg.error) pending.reject(msg.error);
177+
else pending.resolve(msg.result);
178+
}
179+
return;
180+
}
181+
182+
// JSON-RPC requests/notifications from host
183+
if (msg.method === 'ui/initialize') {
184+
_mcpAppMode = true;
185+
_mcpSend({ jsonrpc: '2.0', id: msg.id, result: { capabilities: {} } });
186+
return;
187+
}
188+
189+
if (msg.method === 'ui/notifications/tool-input') {
190+
if (msg.params && msg.params.arguments && msg.params.arguments.machine_id) {
191+
_mcpMachineId = msg.params.arguments.machine_id;
192+
}
193+
return;
194+
}
195+
196+
if (msg.method === 'ui/notifications/tool-result') {
197+
try {
198+
const content = msg.params && msg.params.content;
199+
if (Array.isArray(content) && content.length > 0 && content[0].text) {
200+
const data = JSON.parse(content[0].text);
201+
// Check if this looks like a VizMachine
202+
if (data.id && data.states) {
203+
if (window._statekitApp) {
204+
window._statekitApp.loadMachine(data);
205+
}
206+
}
207+
}
208+
} catch (e) { /* ignore parse errors */ }
209+
return;
210+
}
211+
212+
if (msg.method === 'ui/notifications/theme-changed') {
213+
if (msg.params && msg.params.colorScheme) {
214+
const isLight = msg.params.colorScheme === 'light';
215+
document.documentElement.classList.toggle('light', isLight);
216+
if (window._statekitApp) {
217+
window._statekitApp.setDarkMode(!isLight);
218+
}
219+
}
220+
return;
221+
}
222+
});
223+
// --- End MCP App Bridge ---
224+
146225
createApp({
147226
setup() {
148227
const machine = ref(null);
@@ -155,6 +234,21 @@ <h1>Statekit Visualizer</h1>
155234
const historyList = ref(null);
156235
let cy = null;
157236

237+
// Expose helpers for MCP bridge
238+
window._statekitApp = {
239+
loadMachine(data) {
240+
machine.value = data;
241+
if (data.id) _mcpMachineId = data.id;
242+
currentState.value = resolveInitial(data.initial);
243+
nextTick(() => initGraph());
244+
},
245+
setDarkMode(val) {
246+
darkMode.value = val;
247+
localStorage.setItem('statekit-theme', val ? 'dark' : 'light');
248+
if (cy) updateCyStyles();
249+
}
250+
};
251+
158252
// Theme
159253
const savedTheme = localStorage.getItem('statekit-theme');
160254
if (savedTheme === 'light') {
@@ -351,24 +445,31 @@ <h1>Statekit Visualizer</h1>
351445
}
352446

353447
function sendEvent(eventType) {
354-
// Try MCP tool call if available
355-
if (window.parent !== window) {
356-
try {
357-
window.parent.postMessage({
358-
jsonrpc: '2.0',
359-
method: 'tools/call',
360-
params: {
361-
name: 'send_event',
362-
arguments: {
363-
machine_id: machine.value.id,
364-
event: eventType
448+
// Use MCP App bridge when running inside an MCP host
449+
if (_mcpAppMode && _mcpMachineId) {
450+
const machineId = _mcpMachineId;
451+
callServerTool('send_event', { machine_id: machineId, event: eventType })
452+
.then(result => {
453+
try {
454+
const content = result && result.content;
455+
if (Array.isArray(content) && content.length > 0 && content[0].text) {
456+
const output = JSON.parse(content[0].text);
457+
const from = currentState.value;
458+
if (output.currentState) {
459+
previousState.value = from;
460+
currentState.value = output.currentState;
461+
isDone.value = !!output.done;
462+
if (output.transitioned && from !== output.currentState) {
463+
addHistoryEntry(eventType, from, output.currentState);
464+
animateTransition(from, output.currentState, eventType);
465+
}
466+
highlightCurrent();
467+
}
365468
}
366-
}
367-
}, '*');
368-
return;
369-
} catch (e) {
370-
// Fall through to client-side simulation
371-
}
469+
} catch (e) { /* ignore parse errors */ }
470+
})
471+
.catch(() => { /* ignore errors */ });
472+
return;
372473
}
373474

374475
// Client-side simulation fallback

0 commit comments

Comments
 (0)