MCP live (AI builds the graph)
Start @xenolithengine/graph-mcp-server locally, click Connect, then ask Claude Desktop / Cursor to build a graph. The AI calls list_node_types → add_node → connect_pins → auto_layout. "I describe it, the editor builds it."
import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-react'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { demoSchemas, createCurveWidget, createXYPadWidget } from '@xenolithengine/demo'
import { DemoStage } from '../Layout.js'
// MCP live demo: connect this editor to a running @xenolithengine/graph-mcp-server, then watch Claude/Cursor
// build the graph for you. The editor starts EMPTY — schemas are registered (Source/Filter/Cache/
// Transform/Validate/...) so the LLM has real types to pick from via list_node_types.
//
// Canon: graph SETUP lives in onReady (setupMCP). The connection lifecycle (status / url / log /
// disconnect handle) lives in MCPPanel via useEditor() — state where it's used, ref for the
// imperative disconnect fn, effect for unmount cleanup.
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
const SAMPLE_PROMPTS = [
'Build a simple linear pipeline: Source → Sample → Filter → Cache → Transform → Resolve. Use list_node_types first, then add_node without coordinates, connect pins by label, finally call auto_layout.',
'Show me every available node type, one of each, fan them out from a single Source. End with auto_layout LR.',
'Make a branching pipeline: Source feeds two parallel branches (Filter + Sample), both converge into Validate, then Resolve. Call auto_layout when done.',
'First call list_node_types to see what is available, then design something that uses at least 8 node types and looks visually interesting after auto_layout.',
]
const URL_KEY = 'xeno.mcp.url'
const DEFAULT_URL = 'ws://127.0.0.1:7777?token=devtoken'
function setupMCP(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const s of demoSchemas) editor.registry.register(s)
editor.view.fitView()
}
function MCPPanel() {
const editor = useEditor()
const disconnectRef = useRef<(() => void) | null>(null)
const [url, setUrl] = useState<string>(() => localStorage.getItem(URL_KEY) ?? DEFAULT_URL)
const [status, setStatus] = useState<Status>('idle')
const [err, setErr] = useState<string | null>(null)
const [log, setLog] = useState<string[]>([])
// Unmount cleanup — make sure we drop any live MCP connection when leaving the demo.
useEffect(() => () => { disconnectRef.current?.(); disconnectRef.current = null }, [])
const append = (line: string): void => setLog((prev) => [...prev.slice(-29), `${stamp()} ${line}`])
const connect = async (): Promise<void> => {
localStorage.setItem(URL_KEY, url)
setErr(null); setStatus('connecting'); append(`connect ${url}`)
try {
disconnectRef.current = await editor.connectMCP(url, {
onStatus: (s) => { setStatus(s); append(`status: ${s}`) },
})
} catch (e) {
setStatus('error'); setErr(e instanceof Error ? e.message : String(e))
append(`error: ${e instanceof Error ? e.message : String(e)}`)
}
}
const disconnect = (): void => {
disconnectRef.current?.()
disconnectRef.current = null
setStatus('closed'); append('disconnected')
}
const clearGraph = (): void => {
editor.loadJSON({ version: 'xenolith.v1', nodes: [], edges: [] })
append('graph cleared')
}
const connected = status === 'open'
return (
<>
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 360, padding: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={dotStyle(status)} />
<strong style={{ fontSize: 12, color: 'var(--xeno-text)' }}>MCP {labelFor(status)}</strong>
</div>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={connected || status === 'connecting'}
placeholder="ws://127.0.0.1:7777?token=…"
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 6 }}>
{!connected
? <XenolithButton active onClick={() => void connect()} disabled={status === 'connecting'} style={{ flex: 1 }}>
{status === 'connecting' ? 'Connecting…' : 'Connect'}
</XenolithButton>
: <XenolithButton onClick={disconnect} style={{ flex: 1 }}>Disconnect</XenolithButton>}
<XenolithButton onClick={clearGraph} style={{ flex: '0 0 auto' }}>Clear graph</XenolithButton>
</div>
{err && <div style={errStyle}>{err}</div>}
<details style={{ fontSize: 11, color: 'var(--xeno-muted)' }}>
<summary style={{ cursor: 'pointer' }}>Sample prompts (click to copy)</summary>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{SAMPLE_PROMPTS.map((p, i) => (
<button key={i} onClick={() => void navigator.clipboard.writeText(p)} style={promptBtnStyle} title="Copy">
{p}
</button>
))}
<em style={{ color: 'var(--xeno-muted)' }}>Click to copy, then paste into Claude / Cursor chat.</em>
</div>
</details>
</XenolithPanel>
<XenolithPanel position="bottom-left" style={{ minWidth: 280, maxWidth: 360, maxHeight: 200, padding: 8, overflow: 'auto' }}>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginBottom: 4 }}>Log</div>
{log.length === 0
? <div style={{ fontSize: 11, color: 'var(--xeno-muted)' }}>Empty — connect and ask the AI to build something.</div>
: log.map((l, i) => <div key={i} style={logRowStyle}>{l}</div>)}
</XenolithPanel>
</>
)
}
export function MCPDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupMCP}>
<MCPPanel />
</XenolithGraph>
</DemoStage>
)
}
const dotStyle = (s: Status): React.CSSProperties => ({
width: 8, height: 8, borderRadius: 8,
background: s === 'open' ? '#3ddc97' : s === 'connecting' ? '#fcb400' : s === 'error' ? '#e25b5b' : '#666',
boxShadow: s === 'open' ? '0 0 8px #3ddc9788' : undefined,
})
const labelFor = (s: Status): string => ({ idle: 'idle', connecting: 'connecting…', open: 'connected', closed: 'closed', error: 'error' })[s]
const inputStyle: React.CSSProperties = {
font: 'inherit', fontSize: 11, padding: '6px 8px', borderRadius: 6,
background: 'var(--xeno-bg)', color: 'var(--xeno-text)', border: '1px solid var(--xeno-border)',
}
const errStyle: React.CSSProperties = { fontSize: 11, color: '#e25b5b', whiteSpace: 'pre-wrap' }
const promptBtnStyle: React.CSSProperties = {
textAlign: 'left', font: 'inherit', fontSize: 11, lineHeight: 1.35,
padding: '6px 8px', borderRadius: 6, cursor: 'pointer',
background: 'var(--xeno-bg)', color: 'var(--xeno-text)', border: '1px solid var(--xeno-border)',
}
const logRowStyle: React.CSSProperties = { fontFamily: 'ui-monospace, monospace', fontSize: 10, color: 'var(--xeno-text)' }
const stamp = (): string => new Date().toLocaleTimeString() <script setup lang="ts">
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { demoSchemas, createCurveWidget, createXYPadWidget } from '@xenolithengine/demo'
import MCPPanel from './MCPPanel.vue'
function setupMCP(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.view.fitView()
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="setupMCP">
<MCPPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { onUnmounted, ref } from 'vue'
import { XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-vue'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
const SAMPLE_PROMPTS = [
'Build a simple linear pipeline: Source → Sample → Filter → Cache → Transform → Resolve. Use list_node_types first, then add_node without coordinates, connect pins by label, finally call auto_layout.',
'Show me every available node type, one of each, fan them out from a single Source. End with auto_layout LR.',
'Make a branching pipeline: Source feeds two parallel branches (Filter + Sample), both converge into Validate, then Resolve. Call auto_layout when done.',
'First call list_node_types to see what is available, then design something that uses at least 8 node types and looks visually interesting after auto_layout.',
]
const URL_KEY = 'xeno.mcp.url'
const DEFAULT_URL = 'ws://127.0.0.1:7777?token=devtoken'
const editor = useEditor()
const url = ref(localStorage.getItem(URL_KEY) ?? DEFAULT_URL)
const status = ref<Status>('idle')
const err = ref<string | null>(null)
const log = ref<string[]>([])
let disconnect: (() => void) | null = null
const stamp = (): string => new Date().toLocaleTimeString()
function append(line: string): void {
log.value = [...log.value.slice(-29), `${stamp()} ${line}`]
}
const labelFor = (s: Status): string => ({ idle: 'idle', connecting: 'connecting…', open: 'connected', closed: 'closed', error: 'error' })[s]
function dotColor(s: Status): string {
if (s === 'open') return '#3ddc97'
if (s === 'connecting') return '#fcb400'
if (s === 'error') return '#e25b5b'
return '#666'
}
async function connect(): Promise<void> {
const live = editor.value
if (!live) return
localStorage.setItem(URL_KEY, url.value)
err.value = null
status.value = 'connecting'
append(`connect ${url.value}`)
try {
disconnect = await live.connectMCP(url.value, {
onStatus: (s) => { status.value = s; append(`status: ${s}`) },
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
status.value = 'error'
err.value = message
append(`error: ${message}`)
}
}
function hangUp(): void {
disconnect?.()
disconnect = null
status.value = 'closed'
append('disconnected')
}
function clearGraph(): void {
editor.value?.loadJSON({ version: 'xenolith.v1', nodes: [], edges: [] })
append('graph cleared')
}
function copyPrompt(text: string): void {
void navigator.clipboard.writeText(text)
}
onUnmounted(() => { disconnect?.(); disconnect = null })
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:360px;">
<div style="display:flex;align-items:center;gap:8px;">
<span :style="{ width:'8px', height:'8px', borderRadius:'8px', background:dotColor(status), boxShadow: status === 'open' ? '0 0 8px #3ddc9788' : 'none' }" />
<strong style="font-size:12px;">MCP {{ labelFor(status) }}</strong>
</div>
<input
v-model="url"
:disabled="status === 'open' || status === 'connecting'"
placeholder="ws://127.0.0.1:7777?token=…"
style="font:inherit;font-size:11px;padding:6px 8px;border-radius:6px;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);"
/>
<div style="display:flex;gap:6px;">
<XenolithButton v-if="status !== 'open'" :active="true" :disabled="status === 'connecting'" style="flex:1" @click="connect">
{{ status === 'connecting' ? 'Connecting…' : 'Connect' }}
</XenolithButton>
<XenolithButton v-else style="flex:1" @click="hangUp">Disconnect</XenolithButton>
<XenolithButton @click="clearGraph">Clear graph</XenolithButton>
</div>
<div v-if="err" style="font-size:11px;color:#e25b5b;white-space:pre-wrap;">{{ err }}</div>
<details style="font-size:11px;color:var(--xeno-muted);">
<summary style="cursor:pointer;">Sample prompts (click to copy)</summary>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
<button
v-for="(prompt, i) in SAMPLE_PROMPTS"
:key="i"
type="button"
style="text-align:left;font:inherit;font-size:11px;line-height:1.35;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);"
@click="copyPrompt(prompt)"
>{{ prompt }}</button>
<em>Click to copy, then paste into Claude / Cursor chat.</em>
</div>
</details>
</div>
</XenolithPanel>
<XenolithPanel position="bottom-left">
<div style="min-width:280px;max-width:360px;max-height:200px;overflow:auto;">
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Log</div>
<div v-if="log.length === 0" style="font-size:11px;color:var(--xeno-muted);">Empty — connect and ask the AI to build something.</div>
<div v-for="(line, i) in log" :key="i" style="font-family:ui-monospace,monospace;font-size:10px;">{{ line }}</div>
</div>
</XenolithPanel>
</template> <script lang="ts">
import { onDestroy } from 'svelte'
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { demoSchemas, createCurveWidget, createXYPadWidget } from '@xenolithengine/demo'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
const SAMPLE_PROMPTS = [
'Build a simple linear pipeline: Source → Sample → Filter → Cache → Transform → Resolve. Use list_node_types first, then add_node without coordinates, connect pins by label, finally call auto_layout.',
'Show me every available node type, one of each, fan them out from a single Source. End with auto_layout LR.',
'Make a branching pipeline: Source feeds two parallel branches (Filter + Sample), both converge into Validate, then Resolve. Call auto_layout when done.',
'First call list_node_types to see what is available, then design something that uses at least 8 node types and looks visually interesting after auto_layout.',
]
const URL_KEY = 'xeno.mcp.url'
const DEFAULT_URL = 'ws://127.0.0.1:7777?token=devtoken'
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
let url = $state(localStorage.getItem(URL_KEY) ?? DEFAULT_URL)
let status = $state<Status>('idle')
let err = $state<string | null>(null)
let log = $state<string[]>([])
let disconnect: (() => void) | null = null
const stamp = (): string => new Date().toLocaleTimeString()
function append(line: string): void { log = [...log.slice(-29), `${stamp()} ${line}`] }
const labelFor = (s: Status): string => ({ idle: 'idle', connecting: 'connecting…', open: 'connected', closed: 'closed', error: 'error' })[s]
function dotColor(s: Status): string {
if (s === 'open') return '#3ddc97'
if (s === 'connecting') return '#fcb400'
if (s === 'error') return '#e25b5b'
return '#666'
}
function setup(e: XenolithEditor): void {
e.registerWidget('curve', createCurveWidget())
e.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) e.registry.register(schema)
e.view.fitView()
}
async function connect(): Promise<void> {
if (!editor) return
localStorage.setItem(URL_KEY, url)
err = null
status = 'connecting'
append(`connect ${url}`)
try {
disconnect = await editor.connectMCP(url, {
onStatus: (s) => { status = s; append(`status: ${s}`) },
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
status = 'error'
err = message
append(`error: ${message}`)
}
}
function hangUp(): void {
disconnect?.()
disconnect = null
status = 'closed'
append('disconnected')
}
function clearGraph(): void {
editor?.loadJSON({ version: 'xenolith.v1', nodes: [], edges: [] })
append('graph cleared')
}
onDestroy(() => { disconnect?.(); disconnect = null })
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { editor = e.detail; panelEditor.set(e.detail); setup(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:360px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:8px;height:8px;border-radius:8px;background:{dotColor(status)};box-shadow:{status === 'open' ? '0 0 8px #3ddc9788' : 'none'};"></span>
<strong style="font-size:12px;">MCP {labelFor(status)}</strong>
</div>
<input
bind:value={url}
disabled={status === 'open' || status === 'connecting'}
placeholder="ws://127.0.0.1:7777?token=…"
style="font:inherit;font-size:11px;padding:6px 8px;border-radius:6px;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);"
/>
<div style="display:flex;gap:6px;">
{#if status !== 'open'}
<XenolithButton active disabled={status === 'connecting'} style="flex:1" onclick={() => void connect()}>
{status === 'connecting' ? 'Connecting…' : 'Connect'}
</XenolithButton>
{:else}
<XenolithButton style="flex:1" onclick={hangUp}>Disconnect</XenolithButton>
{/if}
<XenolithButton onclick={clearGraph}>Clear graph</XenolithButton>
</div>
{#if err}<div style="font-size:11px;color:#e25b5b;white-space:pre-wrap;">{err}</div>{/if}
<details style="font-size:11px;color:var(--xeno-muted);">
<summary style="cursor:pointer;">Sample prompts (click to copy)</summary>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
{#each SAMPLE_PROMPTS as prompt}
<button
type="button"
style="text-align:left;font:inherit;font-size:11px;line-height:1.35;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);"
onclick={() => void navigator.clipboard.writeText(prompt)}
>{prompt}</button>
{/each}
<em>Click to copy, then paste into Claude / Cursor chat.</em>
</div>
</details>
</div>
</XenolithPanel>
<XenolithPanel position="bottom-left">
<div style="min-width:280px;max-width:360px;max-height:200px;overflow:auto;">
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Log</div>
{#if log.length === 0}
<div style="font-size:11px;color:var(--xeno-muted);">Empty — connect and ask the AI to build something.</div>
{:else}
{#each log as line}<div style="font-family:ui-monospace,monospace;font-size:10px;">{line}</div>{/each}
{/if}
</div>
</XenolithPanel> import { createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { demoSchemas, createCurveWidget, createXYPadWidget } from '@xenolithengine/demo'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
const SAMPLE_PROMPTS = [
'Build a simple linear pipeline: Source → Sample → Filter → Cache → Transform → Resolve. Use list_node_types first, then add_node without coordinates, connect pins by label, finally call auto_layout.',
'Show me every available node type, one of each, fan them out from a single Source. End with auto_layout LR.',
'Make a branching pipeline: Source feeds two parallel branches (Filter + Sample), both converge into Validate, then Resolve. Call auto_layout when done.',
'First call list_node_types to see what is available, then design something that uses at least 8 node types and looks visually interesting after auto_layout.',
]
const URL_KEY = 'xeno.mcp.url'
const DEFAULT_URL = 'ws://127.0.0.1:7777?token=devtoken'
function setup(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.view.fitView()
}
export function MCPDemo() {
let editor: XenolithEditor | null = null
let disconnect: (() => void) | null = null
const [url, setUrl] = createSignal(localStorage.getItem(URL_KEY) ?? DEFAULT_URL)
const [status, setStatus] = createSignal<Status>('idle')
const [err, setErr] = createSignal<string | null>(null)
const [log, setLog] = createSignal<string[]>([])
onCleanup(() => { disconnect?.(); disconnect = null })
const stamp = (): string => new Date().toLocaleTimeString()
const append = (line: string): void => setLog((prev) => [...prev.slice(-29), `${stamp()} ${line}`])
const labelFor = (s: Status): string => ({ idle: 'idle', connecting: 'connecting…', open: 'connected', closed: 'closed', error: 'error' })[s]
const dotColor = (s: Status): string => s === 'open' ? '#3ddc97' : s === 'connecting' ? '#fcb400' : s === 'error' ? '#e25b5b' : '#666'
const connect = async (): Promise<void> => {
if (!editor) return
localStorage.setItem(URL_KEY, url())
setErr(null)
setStatus('connecting')
append(`connect ${url()}`)
try {
disconnect = await editor.connectMCP(url(), {
onStatus: (s) => { setStatus(s); append(`status: ${s}`) },
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setStatus('error')
setErr(message)
append(`error: ${message}`)
}
}
const hangUp = (): void => {
disconnect?.()
disconnect = null
setStatus('closed')
append('disconnected')
}
const clearGraph = (): void => {
editor?.loadJSON({ version: 'xenolith.v1', nodes: [], edges: [] })
append('graph cleared')
}
const field = 'font:inherit;font-size:11px;padding:6px 8px;border-radius:6px;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);'
const btn = (on: boolean, off: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:${off ? 'default' : 'pointer'};opacity:${off ? '0.4' : '1'};border:1px solid ${on ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { editor = e.detail; setup(e.detail) }}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:8px;max-width:360px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<div style="display:flex;align-items:center;gap:8px;">
<span style={`width:8px;height:8px;border-radius:8px;background:${dotColor(status())};`} />
<strong style="font-size:12px;">MCP {labelFor(status())}</strong>
</div>
<input
value={url()}
disabled={status() === 'open' || status() === 'connecting'}
placeholder="ws://127.0.0.1:7777?token=…"
style={field}
onInput={(e) => setUrl(e.currentTarget.value)}
/>
<div style="display:flex;gap:6px;">
{status() !== 'open'
? <button type="button" style={btn(true, status() === 'connecting') + 'flex:1;'} disabled={status() === 'connecting'} onClick={() => void connect()}>{status() === 'connecting' ? 'Connecting…' : 'Connect'}</button>
: <button type="button" style={btn(false, false) + 'flex:1;'} onClick={hangUp}>Disconnect</button>}
<button type="button" style={btn(false, false)} onClick={clearGraph}>Clear graph</button>
</div>
{err() && <div style="font-size:11px;color:#e25b5b;white-space:pre-wrap;">{err()}</div>}
<details style="font-size:11px;color:var(--xeno-muted);">
<summary style="cursor:pointer;">Sample prompts (click to copy)</summary>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
{SAMPLE_PROMPTS.map((prompt) => (
<button type="button" style={field + 'text-align:left;cursor:pointer;'} onClick={() => void navigator.clipboard.writeText(prompt)}>{prompt}</button>
))}
<em>Click to copy, then paste into Claude / Cursor chat.</em>
</div>
</details>
</div>
<div style="position:absolute;bottom:12px;left:12px;z-index:5;min-width:280px;max-width:360px;max-height:200px;overflow:auto;padding:8px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Log</div>
{log().length === 0
? <div style="font-size:11px;color:var(--xeno-muted);">Empty — connect and ask the AI to build something.</div>
: log().map((line) => <div style="font-family:ui-monospace,monospace;font-size:10px;">{line}</div>)}
</div>
</div>
)
} import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { demoSchemas, createCurveWidget, createXYPadWidget } from '@xenolithengine/demo'
type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
const SAMPLE_PROMPTS = [
'Build a simple linear pipeline: Source → Sample → Filter → Cache → Transform → Resolve. Use list_node_types first, then add_node without coordinates, connect pins by label, finally call auto_layout.',
'Show me every available node type, one of each, fan them out from a single Source. End with auto_layout LR.',
'Make a branching pipeline: Source feeds two parallel branches (Filter + Sample), both converge into Validate, then Resolve. Call auto_layout when done.',
'First call list_node_types to see what is available, then design something that uses at least 8 node types and looks visually interesting after auto_layout.',
]
const URL_KEY = 'xeno.mcp.url'
const DEFAULT_URL = 'ws://127.0.0.1:7777?token=devtoken'
@Component({
selector: 'mcp-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<div class="row">
<span class="dot" [style.background]="dotColor()"></span>
<strong>MCP {{ label() }}</strong>
</div>
<input [value]="url()" (input)="url.set(asValue($event))" [disabled]="status() === 'open' || status() === 'connecting'" placeholder="ws://127.0.0.1:7777?token=…" />
<div class="row">
@if (status() !== 'open') {
<button type="button" class="btn on" [disabled]="status() === 'connecting'" (click)="connect()">{{ status() === 'connecting' ? 'Connecting…' : 'Connect' }}</button>
} @else {
<button type="button" class="btn" (click)="hangUp()">Disconnect</button>
}
<button type="button" class="btn" (click)="clearGraph()">Clear graph</button>
</div>
@if (err()) { <div class="err">{{ err() }}</div> }
<details>
<summary>Sample prompts (click to copy)</summary>
@for (prompt of prompts; track prompt) {
<button type="button" class="prompt" (click)="copy(prompt)">{{ prompt }}</button>
}
<em>Click to copy, then paste into Claude / Cursor chat.</em>
</details>
</div>
<div class="log">
<div class="muted">Log</div>
@if (log().length === 0) {
<div class="muted">Empty — connect and ask the AI to build something.</div>
} @else {
@for (line of log(); track $index) { <div class="line">{{ line }}</div> }
}
</div>
</div>
`,
styles: [`
.card, .log { position:absolute; left:12px; z-index:5; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px; color:var(--xeno-text,#cfcfcf);
font:13px system-ui,sans-serif; }
.card { top:12px; max-width:360px; display:flex; flex-direction:column; gap:8px; }
.log { bottom:12px; min-width:280px; max-width:360px; max-height:200px; overflow:auto; }
.row { display:flex; align-items:center; gap:8px; }
.dot { width:8px; height:8px; border-radius:8px; display:inline-block; }
input, .prompt, .btn { font:inherit; font-size:11px; padding:6px 8px; border-radius:6px;
background:var(--xeno-bg,#111); color:var(--xeno-text,#cfcfcf); border:1px solid var(--xeno-border,#333); }
.btn { font-size:13px; padding:7px 12px; cursor:pointer; background:var(--xeno-elevated,#262626); }
.btn.on { background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); border-color:var(--xeno-accent,#FCB400); }
.btn:disabled { opacity:.4; cursor:default; }
.prompt { text-align:left; cursor:pointer; display:block; width:100%; margin-top:6px; }
.err { font-size:11px; color:#e25b5b; white-space:pre-wrap; }
.muted { font-size:11px; color:var(--xeno-muted,#9a9a9a); }
.line { font:10px ui-monospace,monospace; }
details { font-size:11px; color:var(--xeno-muted,#9a9a9a); }
`],
})
export class MCPDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private editor: XenolithEditor | null = null
private disconnectFn: (() => void) | null = null
readonly prompts = SAMPLE_PROMPTS
url = signal(localStorage.getItem(URL_KEY) ?? DEFAULT_URL)
status = signal<Status>('idle')
err = signal<string | null>(null)
log = signal<string[]>([])
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.editor = editor
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.view.fitView()
}
asValue(event: Event): string { return (event.target as HTMLInputElement).value }
label(): string { return ({ idle: 'idle', connecting: 'connecting…', open: 'connected', closed: 'closed', error: 'error' })[this.status()] }
dotColor(): string {
const s = this.status()
return s === 'open' ? '#3ddc97' : s === 'connecting' ? '#fcb400' : s === 'error' ? '#e25b5b' : '#666'
}
copy(text: string): void { void navigator.clipboard.writeText(text) }
async connect(): Promise<void> {
const editor = this.editor
if (!editor) return
localStorage.setItem(URL_KEY, this.url())
this.err.set(null)
this.status.set('connecting')
this.append(`connect ${this.url()}`)
try {
this.disconnectFn = await editor.connectMCP(this.url(), {
onStatus: (s) => { this.status.set(s); this.append(`status: ${s}`) },
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
this.status.set('error')
this.err.set(message)
this.append(`error: ${message}`)
}
}
hangUp(): void {
this.disconnectFn?.()
this.disconnectFn = null
this.status.set('closed')
this.append('disconnected')
}
clearGraph(): void {
this.editor?.loadJSON({ version: 'xenolith.v1', nodes: [], edges: [] })
this.append('graph cleared')
}
private append(line: string): void {
const stamp = new Date().toLocaleTimeString()
this.log.update((prev) => [...prev.slice(-29), `${stamp} ${line}`])
}
ngOnDestroy(): void {
this.disconnectFn?.()
this.disconnectFn = null
this.graph.destroy()
}
}