@limeade-labs/sparkui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
5
+
6
+ // Registry of known templates
7
+ const registry = {
8
+ 'macro-tracker': require(path.join(TEMPLATES_DIR, 'macro-tracker')),
9
+ 'ws-test': require(path.join(TEMPLATES_DIR, 'ws-test')),
10
+ 'feedback-form': require(path.join(TEMPLATES_DIR, 'feedback-form')),
11
+ 'checkout': require(path.join(TEMPLATES_DIR, 'checkout')),
12
+ 'workout-timer': require(path.join(TEMPLATES_DIR, 'workout-timer')),
13
+ };
14
+
15
+ /**
16
+ * Render a named template with data.
17
+ * @param {string} name - Template name (e.g. "macro-tracker")
18
+ * @param {object} data - Template data
19
+ * @returns {string} Rendered HTML
20
+ * @throws {Error} if template not found
21
+ */
22
+ function render(name, data) {
23
+ const templateFn = registry[name];
24
+ if (!templateFn) {
25
+ const available = Object.keys(registry).join(', ');
26
+ throw new Error(`Unknown template "${name}". Available: ${available}`);
27
+ }
28
+ return templateFn(data);
29
+ }
30
+
31
+ /**
32
+ * Check if a template exists.
33
+ * @param {string} name
34
+ * @returns {boolean}
35
+ */
36
+ function has(name) {
37
+ return name in registry;
38
+ }
39
+
40
+ /**
41
+ * List available template names.
42
+ * @returns {string[]}
43
+ */
44
+ function list() {
45
+ return Object.keys(registry);
46
+ }
47
+
48
+ module.exports = { render, has, list };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Client-side WebSocket code for SparkUI pages.
5
+ * Exported as a string so it can be inlined into HTML templates.
6
+ * No external dependencies.
7
+ */
8
+
9
+ const WS_CLIENT_JS = `
10
+ (function() {
11
+ 'use strict';
12
+
13
+ var pageId = window.__SPARKUI_PAGE_ID__;
14
+ if (!pageId) return;
15
+
16
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
17
+ var wsUrl = proto + '//' + location.host + '/ws?page=' + encodeURIComponent(pageId);
18
+
19
+ var ws = null;
20
+ var reconnectDelay = 1000;
21
+ var maxReconnectDelay = 30000;
22
+ var heartbeatTimer = null;
23
+ var connected = false;
24
+ var messageHandlers = [];
25
+ var pendingQueue = [];
26
+ var completionData = {};
27
+
28
+ // ── Connection Management ──
29
+
30
+ function connect() {
31
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
32
+
33
+ try { ws = new WebSocket(wsUrl); } catch(e) { scheduleReconnect(); return; }
34
+
35
+ ws.onopen = function() {
36
+ connected = true;
37
+ reconnectDelay = 1000;
38
+ startHeartbeat();
39
+ dispatchStatus('connected');
40
+
41
+ // Flush pending messages
42
+ while (pendingQueue.length > 0) {
43
+ var msg = pendingQueue.shift();
44
+ try { ws.send(msg); } catch(e) {}
45
+ }
46
+ };
47
+
48
+ ws.onmessage = function(e) {
49
+ try {
50
+ var msg = JSON.parse(e.data);
51
+ } catch(err) { return; }
52
+
53
+ // Handle built-in message types
54
+ if (msg.type === 'update') {
55
+ location.reload();
56
+ return;
57
+ }
58
+
59
+ if (msg.type === 'destroy') {
60
+ stopHeartbeat();
61
+ document.body.innerHTML = '<div style="background:#111;color:#888;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><h1 style="font-size:3rem;margin-bottom:8px">⚡</h1><p>This page has expired or been removed.</p><p style="color:#555;font-size:0.85rem">SparkUI pages are ephemeral by design.</p></div></div>';
62
+ return;
63
+ }
64
+
65
+ if (msg.type === 'pong') {
66
+ // Server heartbeat response, connection is alive
67
+ return;
68
+ }
69
+
70
+ // Dispatch to registered handlers
71
+ for (var i = 0; i < messageHandlers.length; i++) {
72
+ try { messageHandlers[i](msg); } catch(err) { console.error('[sparkui] handler error:', err); }
73
+ }
74
+ };
75
+
76
+ ws.onclose = function() {
77
+ connected = false;
78
+ stopHeartbeat();
79
+ dispatchStatus('disconnected');
80
+ scheduleReconnect();
81
+ };
82
+
83
+ ws.onerror = function() {
84
+ // onclose will fire after this
85
+ };
86
+ }
87
+
88
+ function scheduleReconnect() {
89
+ setTimeout(function() {
90
+ connect();
91
+ }, reconnectDelay);
92
+ reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
93
+ }
94
+
95
+ // ── Heartbeat ──
96
+
97
+ function startHeartbeat() {
98
+ stopHeartbeat();
99
+ heartbeatTimer = setInterval(function() {
100
+ if (ws && ws.readyState === WebSocket.OPEN) {
101
+ try { ws.send(JSON.stringify({ type: 'heartbeat' })); } catch(e) {}
102
+ }
103
+ }, 25000);
104
+ }
105
+
106
+ function stopHeartbeat() {
107
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
108
+ }
109
+
110
+ // ── Send Messages ──
111
+
112
+ function send(type, data) {
113
+ var msg = JSON.stringify({ type: type, pageId: pageId, data: data || {}, timestamp: Date.now() });
114
+ if (ws && ws.readyState === WebSocket.OPEN) {
115
+ try { ws.send(msg); } catch(e) { pendingQueue.push(msg); }
116
+ } else {
117
+ pendingQueue.push(msg);
118
+ }
119
+ }
120
+
121
+ // ── Event Handlers ──
122
+
123
+ function onMessage(handler) {
124
+ if (typeof handler === 'function') messageHandlers.push(handler);
125
+ }
126
+
127
+ function offMessage(handler) {
128
+ messageHandlers = messageHandlers.filter(function(h) { return h !== handler; });
129
+ }
130
+
131
+ // ── Status Dispatch ──
132
+
133
+ function dispatchStatus(status) {
134
+ var event;
135
+ try {
136
+ event = new CustomEvent('sparkui:status', { detail: { status: status, pageId: pageId } });
137
+ } catch(e) {
138
+ event = document.createEvent('CustomEvent');
139
+ event.initCustomEvent('sparkui:status', true, true, { status: status, pageId: pageId });
140
+ }
141
+ document.dispatchEvent(event);
142
+ }
143
+
144
+ // ── Completion on Unload ──
145
+
146
+ function setCompletionData(data) {
147
+ completionData = data || {};
148
+ }
149
+
150
+ function sendCompletion(data) {
151
+ var payload = data || completionData;
152
+ send('completion', payload);
153
+ }
154
+
155
+ // Send completion on page unload
156
+ window.addEventListener('beforeunload', function() {
157
+ if (ws && ws.readyState === WebSocket.OPEN && Object.keys(completionData).length > 0) {
158
+ // Use sendBeacon-style: just try to send, no guarantee
159
+ try { ws.send(JSON.stringify({ type: 'completion', pageId: pageId, data: completionData, timestamp: Date.now() })); } catch(e) {}
160
+ }
161
+ });
162
+
163
+ // ── Public API ──
164
+
165
+ window.sparkui = {
166
+ send: send,
167
+ onMessage: onMessage,
168
+ offMessage: offMessage,
169
+ setCompletionData: setCompletionData,
170
+ sendCompletion: sendCompletion,
171
+ get connected() { return connected; },
172
+ get pageId() { return pageId; }
173
+ };
174
+
175
+ // Auto-connect
176
+ connect();
177
+ })();
178
+ `;
179
+
180
+ /**
181
+ * Get the client JS with pageId injected.
182
+ * @param {string} pageId
183
+ * @returns {string} Script tag content
184
+ */
185
+ function getClientScript(pageId) {
186
+ return WS_CLIENT_JS.replace('window.__SPARKUI_PAGE_ID__', JSON.stringify(pageId));
187
+ }
188
+
189
+ /**
190
+ * Get the raw client JS string (for manual injection).
191
+ * @returns {string}
192
+ */
193
+ function getRawClientJS() {
194
+ return WS_CLIENT_JS;
195
+ }
196
+
197
+ module.exports = { getClientScript, getRawClientJS };
@@ -0,0 +1,189 @@
1
+ # SparkUI MCP Server
2
+
3
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes SparkUI as tools for AI clients like Claude Desktop, Cursor, Windsurf, and Cline.
4
+
5
+ Any MCP-compatible client can create ephemeral web pages on the fly — checkout flows, workout timers, dashboards, forms — through natural language.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ cd mcp-server
11
+ npm install
12
+ ```
13
+
14
+ ## Configuration
15
+
16
+ The server uses two environment variables:
17
+
18
+ | Variable | Description | Default |
19
+ |---|---|---|
20
+ | `SPARKUI_URL` | Base URL of the SparkUI server | `http://localhost:3457` |
21
+ | `SPARKUI_TOKEN` | Push API authentication token | *(required)* |
22
+
23
+ ## Claude Desktop Setup
24
+
25
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "sparkui": {
31
+ "command": "node",
32
+ "args": ["/absolute/path/to/sparkui/mcp-server/index.js"],
33
+ "env": {
34
+ "SPARKUI_URL": "http://localhost:3457",
35
+ "SPARKUI_TOKEN": "your-push-token"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ ## Cursor / Windsurf Setup
43
+
44
+ Add to `.cursor/mcp.json` or `.windsurf/mcp.json` in your project root:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "sparkui": {
50
+ "command": "node",
51
+ "args": ["/absolute/path/to/sparkui/mcp-server/index.js"],
52
+ "env": {
53
+ "SPARKUI_URL": "http://localhost:3457",
54
+ "SPARKUI_TOKEN": "your-push-token"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Available Tools
62
+
63
+ ### `sparkui_list_templates`
64
+
65
+ List available page templates.
66
+
67
+ **Parameters:** None
68
+
69
+ **Returns:**
70
+ ```json
71
+ {
72
+ "templates": ["macro-tracker", "checkout", "workout-timer", "feedback-form", "ws-test"]
73
+ }
74
+ ```
75
+
76
+ ### `sparkui_list_components`
77
+
78
+ List available components for composing pages.
79
+
80
+ **Parameters:** None
81
+
82
+ **Returns:**
83
+ ```json
84
+ {
85
+ "components": ["header", "button", "timer", "checklist", "progress", ...]
86
+ }
87
+ ```
88
+
89
+ ### `sparkui_push`
90
+
91
+ Create a page from a template.
92
+
93
+ **Parameters:**
94
+ | Name | Type | Required | Description |
95
+ |---|---|---|---|
96
+ | `template` | string | ✅ | Template name (e.g. `"checkout"`) |
97
+ | `data` | object | ✅ | Template data |
98
+ | `ttl` | number | | Time-to-live in seconds (default: 3600) |
99
+ | `og` | object | | Open Graph overrides `{title, description, image}` |
100
+
101
+ **Example:**
102
+ ```json
103
+ {
104
+ "template": "checkout",
105
+ "data": {
106
+ "title": "Your Order",
107
+ "items": [{"name": "Widget", "price": 9.99, "qty": 2}],
108
+ "total": 19.98
109
+ },
110
+ "ttl": 7200
111
+ }
112
+ ```
113
+
114
+ **Returns:**
115
+ ```json
116
+ {
117
+ "id": "abc-123-...",
118
+ "url": "/s/abc-123-...",
119
+ "fullUrl": "http://localhost:3457/s/abc-123-..."
120
+ }
121
+ ```
122
+
123
+ ### `sparkui_compose`
124
+
125
+ Compose a page from individual components.
126
+
127
+ **Parameters:**
128
+ | Name | Type | Required | Description |
129
+ |---|---|---|---|
130
+ | `title` | string | ✅ | Page title |
131
+ | `sections` | array | ✅ | Array of `{type, config}` sections |
132
+ | `ttl` | number | | Time-to-live in seconds |
133
+
134
+ **Example:**
135
+ ```json
136
+ {
137
+ "title": "Quick Poll",
138
+ "sections": [
139
+ {"type": "header", "config": {"text": "What should we build?"}},
140
+ {"type": "checklist", "config": {"items": ["Feature A", "Feature B", "Feature C"]}}
141
+ ]
142
+ }
143
+ ```
144
+
145
+ **Returns:**
146
+ ```json
147
+ {
148
+ "id": "def-456-...",
149
+ "url": "/s/def-456-...",
150
+ "fullUrl": "http://localhost:3457/s/def-456-..."
151
+ }
152
+ ```
153
+
154
+ ### `sparkui_page_status`
155
+
156
+ Check if a page exists and whether it's still active.
157
+
158
+ **Parameters:**
159
+ | Name | Type | Required | Description |
160
+ |---|---|---|---|
161
+ | `id` | string | ✅ | Page UUID |
162
+
163
+ **Returns:**
164
+ ```json
165
+ {
166
+ "exists": true,
167
+ "status": "active",
168
+ "url": "/s/abc-123-...",
169
+ "fullUrl": "http://localhost:3457/s/abc-123-..."
170
+ }
171
+ ```
172
+
173
+ ## How It Works
174
+
175
+ The MCP server communicates with the SparkUI HTTP API over HTTP. It does **not** import SparkUI internals — it's a standalone client that calls `POST /api/push`, `POST /api/compose`, and `GET /` on the SparkUI server.
176
+
177
+ ```
178
+ Claude Desktop / Cursor / Windsurf
179
+ ↓ (MCP stdio)
180
+ SparkUI MCP Server
181
+ ↓ (HTTP)
182
+ SparkUI Server (localhost:3457)
183
+
184
+ Ephemeral web pages
185
+ ```
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
5
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
6
+ const { z } = require('zod/v4');
7
+
8
+ // ── Config ───────────────────────────────────────────────────────────────────
9
+
10
+ const SPARKUI_URL = (process.env.SPARKUI_URL || 'http://localhost:3457').replace(/\/+$/, '');
11
+ const SPARKUI_TOKEN = process.env.SPARKUI_TOKEN || '';
12
+
13
+ // ── HTTP helper ──────────────────────────────────────────────────────────────
14
+
15
+ async function sparkuiRequest(method, path, body) {
16
+ const url = `${SPARKUI_URL}${path}`;
17
+ const headers = { 'Content-Type': 'application/json' };
18
+ if (SPARKUI_TOKEN) {
19
+ headers['Authorization'] = `Bearer ${SPARKUI_TOKEN}`;
20
+ }
21
+
22
+ const options = { method, headers };
23
+ if (body) options.body = JSON.stringify(body);
24
+
25
+ const res = await fetch(url, options);
26
+ const data = await res.json();
27
+
28
+ if (!res.ok) {
29
+ throw new Error(data.error || `SparkUI API error: ${res.status}`);
30
+ }
31
+ return data;
32
+ }
33
+
34
+ // ── MCP Server ───────────────────────────────────────────────────────────────
35
+
36
+ const server = new McpServer({
37
+ name: 'sparkui',
38
+ version: '0.1.0',
39
+ });
40
+
41
+ // Tool: sparkui_list_templates
42
+ server.tool(
43
+ 'sparkui_list_templates',
44
+ 'List available SparkUI page templates. Returns template names that can be used with sparkui_push.',
45
+ {},
46
+ async () => {
47
+ const data = await sparkuiRequest('GET', '/');
48
+ return {
49
+ content: [{ type: 'text', text: JSON.stringify({ templates: data.templates }, null, 2) }],
50
+ };
51
+ }
52
+ );
53
+
54
+ // Tool: sparkui_list_components
55
+ server.tool(
56
+ 'sparkui_list_components',
57
+ 'List available SparkUI components for composing pages with sparkui_compose.',
58
+ {},
59
+ async () => {
60
+ const components = [
61
+ 'header', 'button', 'timer', 'checklist', 'progress',
62
+ 'text', 'image', 'divider', 'card', 'input',
63
+ 'select', 'table', 'chart', 'code', 'form'
64
+ ];
65
+ return {
66
+ content: [{ type: 'text', text: JSON.stringify({ components }, null, 2) }],
67
+ };
68
+ }
69
+ );
70
+
71
+ // Tool: sparkui_push
72
+ server.tool(
73
+ 'sparkui_push',
74
+ 'Push a page from a SparkUI template. Returns the page ID and URL.',
75
+ {
76
+ template: z.string().describe('Template name (e.g. "checkout", "macro-tracker", "workout-timer")'),
77
+ data: z.record(z.string(), z.any()).describe('Template data object'),
78
+ ttl: z.optional(z.number().describe('Time-to-live in seconds (default 3600)')),
79
+ og: z.optional(z.object({
80
+ title: z.optional(z.string()),
81
+ description: z.optional(z.string()),
82
+ image: z.optional(z.string()),
83
+ }).describe('Open Graph metadata overrides')),
84
+ },
85
+ async ({ template, data, ttl, og }) => {
86
+ const body = { template, data };
87
+ if (ttl !== undefined) body.ttl = ttl;
88
+ if (og) body.og = og;
89
+
90
+ const result = await sparkuiRequest('POST', '/api/push', body);
91
+ return {
92
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
93
+ };
94
+ }
95
+ );
96
+
97
+ // Tool: sparkui_compose
98
+ server.tool(
99
+ 'sparkui_compose',
100
+ 'Compose a page from SparkUI components. Returns the page ID and URL.',
101
+ {
102
+ title: z.string().describe('Page title'),
103
+ sections: z.array(z.object({
104
+ type: z.string().describe('Component type (e.g. "header", "button", "timer", "checklist")'),
105
+ config: z.optional(z.record(z.string(), z.any()).describe('Component configuration')),
106
+ })).describe('Array of sections to compose'),
107
+ ttl: z.optional(z.number().describe('Time-to-live in seconds')),
108
+ },
109
+ async ({ title, sections, ttl }) => {
110
+ const body = { title, sections };
111
+ if (ttl !== undefined) body.ttl = ttl;
112
+
113
+ const result = await sparkuiRequest('POST', '/api/compose', body);
114
+ return {
115
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
116
+ };
117
+ }
118
+ );
119
+
120
+ // Tool: sparkui_page_status
121
+ server.tool(
122
+ 'sparkui_page_status',
123
+ 'Check if a SparkUI page exists and get its status.',
124
+ {
125
+ id: z.string().describe('Page UUID'),
126
+ },
127
+ async ({ id }) => {
128
+ try {
129
+ const url = `${SPARKUI_URL}/s/${id}`;
130
+ const res = await fetch(url);
131
+
132
+ if (res.status === 410) {
133
+ return {
134
+ content: [{ type: 'text', text: JSON.stringify({ exists: false, status: 'expired' }, null, 2) }],
135
+ };
136
+ }
137
+
138
+ if (res.status === 404) {
139
+ return {
140
+ content: [{ type: 'text', text: JSON.stringify({ exists: false, status: 'not_found' }, null, 2) }],
141
+ };
142
+ }
143
+
144
+ return {
145
+ content: [{
146
+ type: 'text',
147
+ text: JSON.stringify({
148
+ exists: true,
149
+ status: 'active',
150
+ url: `/s/${id}`,
151
+ fullUrl: `${SPARKUI_URL}/s/${id}`,
152
+ }, null, 2),
153
+ }],
154
+ };
155
+ } catch (err) {
156
+ return {
157
+ content: [{ type: 'text', text: JSON.stringify({ exists: false, error: err.message }, null, 2) }],
158
+ };
159
+ }
160
+ }
161
+ );
162
+
163
+ // ── Start ────────────────────────────────────────────────────────────────────
164
+
165
+ async function main() {
166
+ const transport = new StdioServerTransport();
167
+ await server.connect(transport);
168
+ console.error('SparkUI MCP server running on stdio');
169
+ }
170
+
171
+ main().catch((err) => {
172
+ console.error('Fatal:', err);
173
+ process.exit(1);
174
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@sparkui/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing SparkUI as tools for Claude Desktop, Cursor, Windsurf, and other MCP clients",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "sparkui-mcp": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.12.1"
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@limeade-labs/sparkui",
3
+ "version": "1.0.0",
4
+ "description": "Ephemeral, purpose-built web UIs generated on demand from chat conversations",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "sparkui": "./bin/sparkui.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.js",
11
+ "dev": "node server.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "templates/",
17
+ "server.js",
18
+ "README.md",
19
+ "LICENSE",
20
+ "CONTRIBUTING.md",
21
+ ".env.example",
22
+ "docs/",
23
+ "SKILL.md",
24
+ "mcp-server/index.js",
25
+ "mcp-server/package.json",
26
+ "mcp-server/README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "keywords": [
32
+ "sparkui",
33
+ "ephemeral-ui",
34
+ "ai-agent",
35
+ "web-ui",
36
+ "mcp",
37
+ "websocket",
38
+ "templates"
39
+ ],
40
+ "homepage": "https://sparkui.dev",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/limeade-labs/sparkui.git"
44
+ },
45
+ "author": "Limeade Labs",
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "express": "^4.21.0",
49
+ "uuid": "^11.1.0",
50
+ "ws": "^8.18.0"
51
+ }
52
+ }