@khanglvm/relay 0.10.2 → 0.11.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.
- package/README.md +66 -0
- package/docs/AGENT.md +66 -1
- package/package.json +6 -2
- package/skills/relay/SKILL.md +14 -0
- package/src/cli.js +97 -2
- package/src/mcp-ui/board.js +854 -0
- package/src/mcp-ui/index.html +36 -0
- package/src/mcp.js +451 -0
- package/src/spec.js +65 -3
- package/src/ui/app.js +33 -0
- package/src/ui/blocks.css +25 -0
- package/src/ui/blocks.js +63 -0
- package/src/ui/style.css +11 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>__TITLE__</title>
|
|
8
|
+
<style>/*__CSS__*/
|
|
9
|
+
/*__BLOCKS_CSS__*/
|
|
10
|
+
/* MCP-app inline tweaks: the host owns the outer chrome, so the board paints
|
|
11
|
+
edge-to-edge with no min-height / page background of its own. */
|
|
12
|
+
:root{ color-scheme: light dark; }
|
|
13
|
+
html,body{ background:transparent; }
|
|
14
|
+
body{ margin:0; }
|
|
15
|
+
.wrap{ max-width:none; margin:0; padding:14px 16px 18px; }
|
|
16
|
+
.mcp-status{ font:13px/1.5 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--muted,#8a8580); padding:18px 16px; }
|
|
17
|
+
/* post-submit: a slim one-line confirmation so the iframe collapses small */
|
|
18
|
+
.mcp-done{ display:flex; align-items:center; gap:8px; padding:11px 16px; font:14px/1.4 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--fg,#1c1b19); }
|
|
19
|
+
.mcp-done .mark{ font-size:15px; font-weight:700; color:var(--ok,#4d8a66); }
|
|
20
|
+
.mcp-done .lead{ font-weight:600; }
|
|
21
|
+
.mcp-done .sub{ color:var(--muted,#8a8580); }
|
|
22
|
+
/* in fullscreen the host fills the window — keep text to a readable column */
|
|
23
|
+
.mcp-fullscreen .wrap{ max-width:760px; margin:0 auto; }
|
|
24
|
+
/* progressive preview: shown while the agent is still streaming the spec */
|
|
25
|
+
.mcp-composing{ font-size:13px; color:var(--muted,#8a8580); display:inline-flex; align-items:center; gap:7px; }
|
|
26
|
+
.mcp-composing::before{ content:""; width:7px; height:7px; border-radius:50%; background:var(--accent,#c2674b); animation:mcp-pulse 1s var(--ease,ease) infinite; }
|
|
27
|
+
@keyframes mcp-pulse{ 0%,100%{ opacity:.25; } 50%{ opacity:1; } }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<script id="boot" type="application/json">__BOOT_JSON__</script>
|
|
32
|
+
<div id="app" class="wrap"><div class="mcp-status" id="mcp-status">Loading the relay board…</div></div>
|
|
33
|
+
<script>/*__BLOCKS_JS__*/</script>
|
|
34
|
+
<script>/*__BOARD_JS__*/</script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// mcp.js — relay as an MCP App (SEP-1865, extension "io.modelcontextprotocol/ui").
|
|
2
|
+
//
|
|
3
|
+
// `rly mcp` starts a zero-dependency MCP server over stdio. Register it with a
|
|
4
|
+
// host that supports MCP Apps (Claude desktop/mobile, Codex, …) and relay's
|
|
5
|
+
// boards render INLINE in the conversation instead of opening a browser tab:
|
|
6
|
+
//
|
|
7
|
+
// • the server declares a UI resource ui://relay/board (text/html;profile=mcp-app)
|
|
8
|
+
// • the tools `relay_ask` / `relay_show` link to it via _meta.ui.resourceUri
|
|
9
|
+
// • calling a tool returns the (normalized) board spec as structuredContent;
|
|
10
|
+
// the host renders the resource in a sandboxed iframe and forwards the spec
|
|
11
|
+
// • the iframe collects the user's answers and sends them back to the model
|
|
12
|
+
// via `ui/update-model-context`
|
|
13
|
+
//
|
|
14
|
+
// Framing is the MCP stdio transport: newline-delimited JSON-RPC, one message
|
|
15
|
+
// per line, never embedded newlines. stdout carries ONLY protocol messages;
|
|
16
|
+
// everything else goes to stderr.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { normalizeSpec, SPEC_SCHEMA } from './spec.js';
|
|
23
|
+
import { CliError } from './util.js';
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const UI_DIR = path.join(__dirname, 'ui');
|
|
27
|
+
const MCP_UI_DIR = path.join(__dirname, 'mcp-ui');
|
|
28
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
29
|
+
const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
|
|
30
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
31
|
+
|
|
32
|
+
const UI_EXT = 'io.modelcontextprotocol/ui';
|
|
33
|
+
const UI_MIME = 'text/html;profile=mcp-app';
|
|
34
|
+
const BOARD_URI = 'ui://relay/board';
|
|
35
|
+
const VENDOR_PREFIX = 'ui://relay/vendor/';
|
|
36
|
+
// Echoed back to the client when it doesn't pin a version we recognize.
|
|
37
|
+
const DEFAULT_PROTOCOL = '2025-06-18';
|
|
38
|
+
const DEFAULT_HTTP_PORT = 4319;
|
|
39
|
+
|
|
40
|
+
const escapeHtml = (s) =>
|
|
41
|
+
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
42
|
+
|
|
43
|
+
// ---------- UI asset assembly (cached for the process lifetime) ----------
|
|
44
|
+
const _cache = new Map();
|
|
45
|
+
function readAsset(dir, name) {
|
|
46
|
+
const key = dir + '\0' + name;
|
|
47
|
+
if (_cache.has(key)) return _cache.get(key);
|
|
48
|
+
let content = '';
|
|
49
|
+
try { content = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { content = ''; }
|
|
50
|
+
_cache.set(key, content);
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The single self-contained board page served as the ui:// resource: the shared
|
|
55
|
+
// stylesheet + block renderer + the MCP-app client, all inlined (no /vendor or
|
|
56
|
+
// /api routes exist in the sandbox; vendors load over the bridge on demand).
|
|
57
|
+
function buildBoardPage() {
|
|
58
|
+
if (_cache.has('__page__')) return _cache.get('__page__');
|
|
59
|
+
const html = readAsset(MCP_UI_DIR, 'index.html');
|
|
60
|
+
const boot = { version: PKG.version, boardId: 'mcp-' + Date.now().toString(36) };
|
|
61
|
+
const page = html
|
|
62
|
+
.split('__TITLE__').join('Relay')
|
|
63
|
+
.split('/*__CSS__*/').join(readAsset(UI_DIR, 'style.css'))
|
|
64
|
+
.split('/*__BLOCKS_CSS__*/').join(readAsset(UI_DIR, 'blocks.css'))
|
|
65
|
+
.split('/*__BLOCKS_JS__*/').join(readAsset(UI_DIR, 'blocks.js'))
|
|
66
|
+
.split('/*__BOARD_JS__*/').join(readAsset(MCP_UI_DIR, 'board.js'))
|
|
67
|
+
.split('__BOOT_JSON__').join(JSON.stringify(boot).replace(/</g, '\\u003c'));
|
|
68
|
+
_cache.set('__page__', page);
|
|
69
|
+
return page;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function vendorText(name) {
|
|
73
|
+
// No traversal: only a bare filename inside VENDOR_DIR.
|
|
74
|
+
if (!/^[\w.-]+$/.test(name)) return null;
|
|
75
|
+
const target = path.join(VENDOR_DIR, name);
|
|
76
|
+
if (target !== VENDOR_DIR && !target.startsWith(VENDOR_DIR + path.sep)) return null;
|
|
77
|
+
try { return fs.readFileSync(target, 'utf8'); } catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------- tool + resource descriptors ----------
|
|
81
|
+
// The board spec, as the tool input schema: SPEC_SCHEMA minus the result-only
|
|
82
|
+
// fields (annotations / blockEdits are returned, never supplied).
|
|
83
|
+
function inputSchema() {
|
|
84
|
+
const props = { ...SPEC_SCHEMA.properties };
|
|
85
|
+
delete props.annotations;
|
|
86
|
+
delete props.blockEdits;
|
|
87
|
+
return { type: 'object', properties: props, anyOf: SPEC_SCHEMA.anyOf };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// _meta linking a tool to the UI resource. We emit the field under several keys
|
|
91
|
+
// for cross-host compatibility: the spec's `_meta.ui`, the extension-id key, and
|
|
92
|
+
// OpenAI's `openai/outputTemplate` (ChatGPT / Codex Apps SDK).
|
|
93
|
+
function uiToolMeta() {
|
|
94
|
+
const ui = { resourceUri: BOARD_URI, visibility: ['model', 'app'] };
|
|
95
|
+
return { ui, [UI_EXT]: ui, 'openai/outputTemplate': BOARD_URI };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tools() {
|
|
99
|
+
const schema = inputSchema();
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
name: 'relay_ask',
|
|
103
|
+
description:
|
|
104
|
+
'Ask the user structured questions on an interactive board rendered inline in this app — real form controls (single/multi choice, yes/no, scale, text) plus rich blocks (markdown, code, diff, table, chart, mermaid, graphviz, image, html). Use this instead of asking questions in plain text whenever you need decisions, requirements, or feedback. The user fills it in and submits; their answers come back to you. Pass a board spec (see inputSchema).',
|
|
105
|
+
inputSchema: schema,
|
|
106
|
+
_meta: uiToolMeta(),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'relay_show',
|
|
110
|
+
description:
|
|
111
|
+
'Present work to the user on an inline board WITHOUT necessarily asking questions — a plan, an architecture diagram, data, a diff, a prototype — using rich blocks (markdown, mermaid, graphviz, chart, table, code, diff, image, html). The board shows a Submit/Acknowledge button. Same spec as relay_ask; typically blocks-only.',
|
|
112
|
+
inputSchema: schema,
|
|
113
|
+
_meta: uiToolMeta(),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resources() {
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
uri: BOARD_URI,
|
|
122
|
+
name: 'Relay board',
|
|
123
|
+
description: 'The interactive relay board UI (rendered inline by relay_ask / relay_show).',
|
|
124
|
+
mimeType: UI_MIME,
|
|
125
|
+
_meta: { ui: resourceMeta() },
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Per-resource UI hints (CSP / framing). Hosts that ignore these still work; the
|
|
131
|
+
// frame allowances cover YouTube/Vimeo `video` blocks and blob: html previews.
|
|
132
|
+
function resourceMeta() {
|
|
133
|
+
return {
|
|
134
|
+
prefersBorder: false,
|
|
135
|
+
csp: {
|
|
136
|
+
connectDomains: [],
|
|
137
|
+
resourceDomains: ['https://*', 'data:', 'blob:'],
|
|
138
|
+
frameDomains: ['https://www.youtube-nocookie.com', 'https://player.vimeo.com', 'blob:', 'data:'],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- request routing ----------
|
|
144
|
+
function buildResult(method, params, clientProtocol) {
|
|
145
|
+
switch (method) {
|
|
146
|
+
case 'initialize':
|
|
147
|
+
return {
|
|
148
|
+
protocolVersion: typeof params.protocolVersion === 'string' ? params.protocolVersion : DEFAULT_PROTOCOL,
|
|
149
|
+
capabilities: {
|
|
150
|
+
tools: { listChanged: false },
|
|
151
|
+
resources: { listChanged: false },
|
|
152
|
+
experimental: {},
|
|
153
|
+
extensions: { [UI_EXT]: { mimeTypes: [UI_MIME] } },
|
|
154
|
+
},
|
|
155
|
+
serverInfo: { name: 'relay', version: PKG.version },
|
|
156
|
+
instructions:
|
|
157
|
+
'relay renders interactive boards inline. Call relay_ask to collect decisions/feedback with real form controls, or relay_show to present plans/diagrams/data — instead of asking in plain text. Read the user\'s answers from the structuredContent that returns after they submit.',
|
|
158
|
+
};
|
|
159
|
+
case 'ping':
|
|
160
|
+
return {};
|
|
161
|
+
case 'tools/list':
|
|
162
|
+
return { tools: tools() };
|
|
163
|
+
case 'resources/list':
|
|
164
|
+
return { resources: resources() };
|
|
165
|
+
case 'resources/templates/list':
|
|
166
|
+
return { resourceTemplates: [] };
|
|
167
|
+
case 'prompts/list':
|
|
168
|
+
return { prompts: [] };
|
|
169
|
+
case 'resources/read':
|
|
170
|
+
return readResource(params);
|
|
171
|
+
case 'tools/call':
|
|
172
|
+
return callTool(params);
|
|
173
|
+
default: {
|
|
174
|
+
const err = new Error('method not found: ' + method);
|
|
175
|
+
err.code = -32601;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readResource(params) {
|
|
182
|
+
const uri = params && params.uri;
|
|
183
|
+
if (uri === BOARD_URI) {
|
|
184
|
+
return { contents: [{ uri: BOARD_URI, mimeType: UI_MIME, text: buildBoardPage(), _meta: { ui: resourceMeta() } }] };
|
|
185
|
+
}
|
|
186
|
+
if (typeof uri === 'string' && uri.startsWith(VENDOR_PREFIX)) {
|
|
187
|
+
const name = uri.slice(VENDOR_PREFIX.length);
|
|
188
|
+
const text = vendorText(name);
|
|
189
|
+
if (text == null) { const e = new Error('vendor not found: ' + name); e.code = -32002; throw e; }
|
|
190
|
+
return { contents: [{ uri, mimeType: 'application/javascript', text }] };
|
|
191
|
+
}
|
|
192
|
+
const e = new Error('resource not found: ' + uri);
|
|
193
|
+
e.code = -32002;
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A tool call: normalize the spec and hand it to the host as structuredContent.
|
|
198
|
+
// The host renders ui://relay/board and forwards this spec to the iframe, which
|
|
199
|
+
// collects the answers and returns them via ui/update-model-context. Spec errors
|
|
200
|
+
// come back as an isError tool result (not a protocol error) so the model can
|
|
201
|
+
// see and fix them.
|
|
202
|
+
function callTool(params) {
|
|
203
|
+
const name = params && params.name;
|
|
204
|
+
if (name !== 'relay_ask' && name !== 'relay_show') {
|
|
205
|
+
return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
|
|
206
|
+
}
|
|
207
|
+
const args = (params && params.arguments && typeof params.arguments === 'object') ? params.arguments : {};
|
|
208
|
+
let spec;
|
|
209
|
+
try {
|
|
210
|
+
spec = normalizeSpec(args);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
|
|
213
|
+
return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
|
|
214
|
+
}
|
|
215
|
+
const nQ = spec.questions.length;
|
|
216
|
+
const summary = nQ
|
|
217
|
+
? `Relay board "${spec.title}" is now displayed to the user (${nQ} question${nQ === 1 ? '' : 's'}). They will fill it in and submit; their answers will be delivered back to you. Do NOT re-ask these questions in plain text — wait for the submission.`
|
|
218
|
+
: `Relay board "${spec.title}" is now displayed to the user. They can review it and acknowledge; any feedback will be delivered back to you.`;
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: 'text', text: summary }],
|
|
221
|
+
structuredContent: { spec, mode: name === 'relay_show' ? 'show' : 'ask' },
|
|
222
|
+
_meta: { ui: { resourceUri: BOARD_URI }, [UI_EXT]: { resourceUri: BOARD_URI } },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- stdio loop ----------
|
|
227
|
+
export function runMcp() {
|
|
228
|
+
// The peer closing stdout (EPIPE) is the only reason to stop writing — NOT a
|
|
229
|
+
// `false` return from write(), which just signals backpressure (the 145KB
|
|
230
|
+
// board page reliably triggers it). Honoring backpressure as "stop" would
|
|
231
|
+
// silently drop every response after a large one.
|
|
232
|
+
let stdoutOpen = true;
|
|
233
|
+
process.stdout.on('error', () => { stdoutOpen = false; });
|
|
234
|
+
const send = (obj) => {
|
|
235
|
+
if (!stdoutOpen) return;
|
|
236
|
+
try { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
237
|
+
catch { stdoutOpen = false; }
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function handleLine(line) {
|
|
241
|
+
let msg;
|
|
242
|
+
try { msg = JSON.parse(line); } catch {
|
|
243
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const messages = Array.isArray(msg) ? msg : [msg];
|
|
247
|
+
for (const m of messages) {
|
|
248
|
+
if (!m || typeof m !== 'object') continue;
|
|
249
|
+
const isRequest = m.id !== undefined && m.id !== null && typeof m.method === 'string';
|
|
250
|
+
if (typeof m.method !== 'string') continue; // a response to us — we issue none
|
|
251
|
+
let result;
|
|
252
|
+
try {
|
|
253
|
+
result = buildResult(m.method, m.params || {});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (isRequest) {
|
|
256
|
+
send({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (isRequest) send({ jsonrpc: '2.0', id: m.id, result });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let buf = '';
|
|
265
|
+
process.stdin.setEncoding('utf8');
|
|
266
|
+
process.stdin.on('data', (chunk) => {
|
|
267
|
+
buf += chunk;
|
|
268
|
+
let idx;
|
|
269
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
270
|
+
const line = buf.slice(0, idx);
|
|
271
|
+
buf = buf.slice(idx + 1);
|
|
272
|
+
const trimmed = line.trim();
|
|
273
|
+
if (trimmed) handleLine(trimmed);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Resolve only when the input stream closes (host disconnected). Returning a
|
|
278
|
+
// promise that stays pending keeps the CLI's post-run exit timer from firing.
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
const finish = () => resolve(0);
|
|
281
|
+
process.stdin.on('end', finish);
|
|
282
|
+
process.stdin.on('close', finish);
|
|
283
|
+
process.on('SIGINT', finish);
|
|
284
|
+
process.on('SIGTERM', finish);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------- Streamable HTTP transport ----------
|
|
289
|
+
// `rly mcp --http` serves the SAME server over MCP's Streamable HTTP transport
|
|
290
|
+
// instead of stdio, so a host that connects over the network (Claude web/mobile,
|
|
291
|
+
// a remote/custom connector) can render relay boards — not just a local desktop
|
|
292
|
+
// host. relay is stateless (each tools/call is self-contained and the board
|
|
293
|
+
// state lives in the iframe), so every POST is routed through buildResult and
|
|
294
|
+
// answered with a single application/json response; we never need SSE.
|
|
295
|
+
//
|
|
296
|
+
// • POST <endpoint> — one JSON-RPC request → one JSON response; a
|
|
297
|
+
// notification/response → 202 Accepted.
|
|
298
|
+
// • GET <endpoint> — 405 (we open no server→client stream).
|
|
299
|
+
// • DELETE — 200 (no session state to drop).
|
|
300
|
+
// • OPTIONS — CORS preflight (never auth-gated).
|
|
301
|
+
//
|
|
302
|
+
// Security per the transport spec: bind to localhost by default, validate the
|
|
303
|
+
// Origin header (localhost only unless --allow-origin opts in), and support an
|
|
304
|
+
// optional bearer token for an exposed endpoint.
|
|
305
|
+
const HTTP_PATH = '/mcp';
|
|
306
|
+
const MAX_BODY = 16 * 1024 * 1024;
|
|
307
|
+
|
|
308
|
+
function corsHeaders(origin) {
|
|
309
|
+
return {
|
|
310
|
+
'access-control-allow-origin': origin || '*',
|
|
311
|
+
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
|
|
312
|
+
'access-control-allow-headers': 'content-type, authorization, mcp-session-id, mcp-protocol-version, last-event-id',
|
|
313
|
+
'access-control-expose-headers': 'Mcp-Session-Id',
|
|
314
|
+
'access-control-max-age': '86400',
|
|
315
|
+
vary: 'Origin',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function originAllowed(origin, allowOrigin) {
|
|
320
|
+
if (!origin) return true; // non-browser client sends no Origin
|
|
321
|
+
if (allowOrigin === '*') return true;
|
|
322
|
+
if (allowOrigin && origin === allowOrigin) return true;
|
|
323
|
+
try {
|
|
324
|
+
const h = new URL(origin).hostname;
|
|
325
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function safeEqual(a, b) {
|
|
332
|
+
const A = Buffer.from(String(a));
|
|
333
|
+
const B = Buffer.from(String(b));
|
|
334
|
+
if (A.length !== B.length) return false;
|
|
335
|
+
try { return crypto.timingSafeEqual(A, B); } catch { return false; }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function healthPage(endpoint) {
|
|
339
|
+
return (
|
|
340
|
+
'<!doctype html><meta charset="utf-8"><title>relay MCP</title>' +
|
|
341
|
+
'<style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;max-width:640px;margin:14vh auto;padding:0 20px;color:#1c1b19;background:#fcfbf9}' +
|
|
342
|
+
'code{background:#f1efe8;padding:2px 6px;border-radius:5px;font-size:13px}h1{color:#c2674b;font-size:20px}</style>' +
|
|
343
|
+
'<h1>relay · MCP Apps server</h1>' +
|
|
344
|
+
'<p>This is relay running over the <b>Streamable HTTP</b> MCP transport. Point an MCP host at the endpoint below and call <code>relay_ask</code> / <code>relay_show</code>.</p>' +
|
|
345
|
+
'<p>MCP endpoint: <code>' + escapeHtml(endpoint) + '</code></p>' +
|
|
346
|
+
'<p>It speaks JSON-RPC over HTTP POST — open it in a browser and you get this page; an MCP client gets the protocol.</p>'
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token = '', allowOrigin = '' } = {}) {
|
|
351
|
+
const requireAuth = Boolean(token);
|
|
352
|
+
const server = http.createServer((req, res) => {
|
|
353
|
+
const origin = req.headers.origin || '';
|
|
354
|
+
const url = (req.url || '/').split('?')[0];
|
|
355
|
+
const base = corsHeaders(origin);
|
|
356
|
+
|
|
357
|
+
if (req.method === 'OPTIONS') {
|
|
358
|
+
if (!originAllowed(origin, allowOrigin)) { res.writeHead(403); return res.end(); }
|
|
359
|
+
res.writeHead(204, base); return res.end();
|
|
360
|
+
}
|
|
361
|
+
// Health page only at GET / — POST / is still accepted as the MCP endpoint.
|
|
362
|
+
if (req.method === 'GET' && url === '/') {
|
|
363
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
364
|
+
return res.end(healthPage(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}${HTTP_PATH}`));
|
|
365
|
+
}
|
|
366
|
+
if (url !== HTTP_PATH && url !== '/') { res.writeHead(404, base); return res.end('not found'); }
|
|
367
|
+
if (!originAllowed(origin, allowOrigin)) { res.writeHead(403, base); return res.end('origin not allowed'); }
|
|
368
|
+
if (req.method === 'GET') { res.writeHead(405, { ...base, allow: 'POST, DELETE, OPTIONS' }); return res.end(); }
|
|
369
|
+
if (req.method === 'DELETE') { res.writeHead(200, { ...base, 'content-type': 'application/json' }); return res.end('{}'); }
|
|
370
|
+
if (req.method !== 'POST') { res.writeHead(405, { ...base, allow: 'POST, DELETE, OPTIONS' }); return res.end(); }
|
|
371
|
+
if (requireAuth) {
|
|
372
|
+
const auth = req.headers.authorization || '';
|
|
373
|
+
if (!(auth.startsWith('Bearer ') && safeEqual(auth.slice(7), token))) {
|
|
374
|
+
res.writeHead(401, { ...base, 'www-authenticate': 'Bearer' }); return res.end('unauthorized');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let body = '';
|
|
379
|
+
let aborted = false;
|
|
380
|
+
req.on('data', (c) => { body += c; if (body.length > MAX_BODY) { aborted = true; req.destroy(); } });
|
|
381
|
+
req.on('end', () => {
|
|
382
|
+
if (aborted) { res.writeHead(413, base); return res.end(); }
|
|
383
|
+
let msg;
|
|
384
|
+
try { msg = JSON.parse(body); } catch {
|
|
385
|
+
res.writeHead(400, { ...base, 'content-type': 'application/json' });
|
|
386
|
+
return res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
|
|
387
|
+
}
|
|
388
|
+
const list = Array.isArray(msg) ? msg : [msg];
|
|
389
|
+
const responses = [];
|
|
390
|
+
let isInit = false;
|
|
391
|
+
for (const m of list) {
|
|
392
|
+
if (!m || typeof m !== 'object' || typeof m.method !== 'string') continue;
|
|
393
|
+
if (m.method === 'initialize') isInit = true;
|
|
394
|
+
const isRequest = m.id !== undefined && m.id !== null;
|
|
395
|
+
try {
|
|
396
|
+
const result = buildResult(m.method, m.params || {});
|
|
397
|
+
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, result });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (!responses.length) { res.writeHead(202, base); return res.end(); } // notifications/responses only
|
|
403
|
+
const headers = { ...base, 'content-type': 'application/json; charset=utf-8' };
|
|
404
|
+
if (isInit) headers['mcp-session-id'] = crypto.randomUUID();
|
|
405
|
+
res.writeHead(200, headers);
|
|
406
|
+
res.end(JSON.stringify(Array.isArray(msg) ? responses : responses[0]));
|
|
407
|
+
});
|
|
408
|
+
req.on('error', () => { try { res.writeHead(400, base); res.end(); } catch { /* already gone */ } });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
server.on('error', (e) => { process.stderr.write('relay mcp http error: ' + ((e && e.message) || e) + '\n'); resolve(1); });
|
|
413
|
+
server.listen(port, host, () => {
|
|
414
|
+
const shown = host === '0.0.0.0' ? 'localhost' : host;
|
|
415
|
+
process.stderr.write(`relay MCP (Streamable HTTP) listening on http://${shown}:${port}${HTTP_PATH}\n`);
|
|
416
|
+
if (!requireAuth && host !== '127.0.0.1' && host !== 'localhost') {
|
|
417
|
+
process.stderr.write(' warning: bound to a non-local interface with no --token — anyone who can reach this port can drive relay.\n');
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
const stop = () => { try { server.close(); } catch { /* noop */ } resolve(0); };
|
|
421
|
+
process.on('SIGINT', stop);
|
|
422
|
+
process.on('SIGTERM', stop);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The setup snippet printed by `rly mcp config` / `rly mcp install --print`.
|
|
427
|
+
// Returns { json, toml, paths } so the CLI can present per-host instructions.
|
|
428
|
+
export function mcpConfig({ command = 'rly', httpPort = DEFAULT_HTTP_PORT } = {}) {
|
|
429
|
+
return {
|
|
430
|
+
command,
|
|
431
|
+
args: ['mcp'],
|
|
432
|
+
httpPort,
|
|
433
|
+
httpUrl: `http://127.0.0.1:${httpPort}${HTTP_PATH}`,
|
|
434
|
+
// Claude Desktop / generic MCP JSON config (claude_desktop_config.json,
|
|
435
|
+
// .mcp.json, VS Code mcp.json, …).
|
|
436
|
+
json: {
|
|
437
|
+
mcpServers: {
|
|
438
|
+
relay: { command, args: ['mcp'] },
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
// Codex CLI (~/.codex/config.toml).
|
|
442
|
+
toml: `[mcp_servers.relay]\ncommand = "${command}"\nargs = ["mcp"]\n`,
|
|
443
|
+
// Remote / web+mobile hosts (Claude web/mobile custom connector, etc.):
|
|
444
|
+
// run `rly mcp --http` somewhere reachable and register the URL.
|
|
445
|
+
http: {
|
|
446
|
+
run: `${command} mcp --http --port ${httpPort}`,
|
|
447
|
+
url: `http://127.0.0.1:${httpPort}${HTTP_PATH}`,
|
|
448
|
+
note: 'For web/mobile, expose this URL (e.g. a cloudflared/ngrok tunnel) and add it as a custom connector. Use --token <secret> + --allow-origin <host> when exposing it publicly.',
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
package/src/spec.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { CliError } from './util.js';
|
|
4
4
|
|
|
5
|
-
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale'];
|
|
5
|
+
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color'];
|
|
6
6
|
|
|
7
7
|
const ALIASES = {
|
|
8
8
|
radio: 'single',
|
|
@@ -19,13 +19,15 @@ const ALIASES = {
|
|
|
19
19
|
long: 'textarea',
|
|
20
20
|
rating: 'scale',
|
|
21
21
|
likert: 'scale',
|
|
22
|
+
colour: 'color',
|
|
23
|
+
swatch: 'color',
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 360 };
|
|
25
27
|
|
|
26
28
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
27
29
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
28
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image'];
|
|
30
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette'];
|
|
29
31
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
30
32
|
|
|
31
33
|
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
@@ -360,6 +362,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
360
362
|
return block;
|
|
361
363
|
}
|
|
362
364
|
|
|
365
|
+
if (type === 'palette') {
|
|
366
|
+
// Accept either { palettes: [{name, colors:[…]}, …] } or a single-palette
|
|
367
|
+
// shorthand { name?, colors:[…] }. Each palette needs a non-empty colors
|
|
368
|
+
// array of CSS color strings (#rrggbb, rgb(), hsl(), named — kept as given).
|
|
369
|
+
const rawList = Array.isArray(rawBlock.palettes)
|
|
370
|
+
? rawBlock.palettes
|
|
371
|
+
: (Array.isArray(rawBlock.colors) ? [{ name: rawBlock.name, sub: rawBlock.sub, colors: rawBlock.colors }] : null);
|
|
372
|
+
if (!rawList || !rawList.length) {
|
|
373
|
+
throw new CliError(`${where}: palette block needs "palettes" (array) or a "colors" array.`);
|
|
374
|
+
}
|
|
375
|
+
const palettes = rawList.map((p, j) => {
|
|
376
|
+
if (!p || typeof p !== 'object') throw new CliError(`${where}.palettes[${j}]: must be an object with "colors".`);
|
|
377
|
+
const colors = (Array.isArray(p.colors) ? p.colors : [])
|
|
378
|
+
.map((c) => asStr(c).trim())
|
|
379
|
+
.filter(Boolean);
|
|
380
|
+
if (!colors.length) throw new CliError(`${where}.palettes[${j}]: needs a non-empty "colors" array.`);
|
|
381
|
+
const out = { colors };
|
|
382
|
+
if (p.name !== undefined) out.name = asStr(p.name);
|
|
383
|
+
const sub = p.sub ?? p.mood;
|
|
384
|
+
if (sub !== undefined) out.sub = asStr(sub);
|
|
385
|
+
const tag = p.badge ?? p.tag;
|
|
386
|
+
if (tag !== undefined) out.tag = asStr(tag);
|
|
387
|
+
if (p.tagTone !== undefined) out.tagTone = asStr(p.tagTone).trim().toLowerCase();
|
|
388
|
+
if (p.featured === true) out.featured = true;
|
|
389
|
+
return out;
|
|
390
|
+
});
|
|
391
|
+
const block = { id, type: 'palette', palettes };
|
|
392
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
393
|
+
return block;
|
|
394
|
+
}
|
|
395
|
+
|
|
363
396
|
// type === 'html'
|
|
364
397
|
const html = readBlockHtml(rawBlock, cwd, where);
|
|
365
398
|
if (!html) {
|
|
@@ -487,6 +520,14 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
487
520
|
q.maxLabel = asStr(rq.maxLabel);
|
|
488
521
|
}
|
|
489
522
|
|
|
523
|
+
if (type === 'color') {
|
|
524
|
+
// Optional preset swatches the user can click beside the native picker.
|
|
525
|
+
const presets = (Array.isArray(rq.presets) ? rq.presets : [])
|
|
526
|
+
.map((c) => asStr(c).trim())
|
|
527
|
+
.filter(Boolean);
|
|
528
|
+
if (presets.length) q.presets = presets;
|
|
529
|
+
}
|
|
530
|
+
|
|
490
531
|
if (rq.default !== undefined) q.default = rq.default;
|
|
491
532
|
spec.questions.push(q);
|
|
492
533
|
});
|
|
@@ -555,6 +596,26 @@ const BLOCK_SCHEMA = {
|
|
|
555
596
|
htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
|
|
556
597
|
src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline). video: a YouTube/Vimeo URL (embeds an iframe player), an http(s) media URL, or a local video file (mp4/webm/ogv/mov/mkv/m4v — streamed from the server, never embedded).' },
|
|
557
598
|
alt: { type: 'string', description: 'image: alt text / annotation label. video: accessible title for the player.' },
|
|
599
|
+
palettes: {
|
|
600
|
+
type: 'array',
|
|
601
|
+
description: 'palette: one or more color palettes to display as swatch cards. Each swatch reveals its hex on hover and copies it on click; mark one {featured:true} to show it as a larger spotlight. Each item: {name?, sub? (or mood?), tag? (or badge?), tagTone? (warm|cool|neutral|nature|bold|digital), featured?, colors:[…]}.',
|
|
602
|
+
items: {
|
|
603
|
+
type: 'object',
|
|
604
|
+
properties: {
|
|
605
|
+
name: { type: 'string' },
|
|
606
|
+
sub: { type: 'string' },
|
|
607
|
+
mood: { type: 'string' },
|
|
608
|
+
tag: { type: 'string' },
|
|
609
|
+
badge: { type: 'string' },
|
|
610
|
+
tagTone: { type: 'string', enum: ['warm', 'cool', 'neutral', 'nature', 'bold', 'digital'] },
|
|
611
|
+
featured: { type: 'boolean' },
|
|
612
|
+
colors: { type: 'array', items: { type: 'string' }, description: 'CSS colors — #rrggbb, rgb()/hsl(), or named.' },
|
|
613
|
+
},
|
|
614
|
+
required: ['colors'],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
colors: { type: 'array', items: { type: 'string' }, description: 'palette shorthand: colors for a single palette (use "palettes" for several named ones). Pairs with block-level "name".' },
|
|
618
|
+
name: { type: 'string', description: 'palette shorthand: name for the single "colors" palette.' },
|
|
558
619
|
height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid/graphviz/plantuml/image natural (max 1200, scrolls).' },
|
|
559
620
|
},
|
|
560
621
|
},
|
|
@@ -584,7 +645,7 @@ export const SPEC_SCHEMA = {
|
|
|
584
645
|
required: ['label'],
|
|
585
646
|
properties: {
|
|
586
647
|
id: { type: 'string', description: 'Answer key in the result JSON. Defaults to q1, q2, …' },
|
|
587
|
-
type: { type: 'string', enum: ['single', 'multi', 'yesno', 'text', 'textarea', 'scale'], default: 'text' },
|
|
648
|
+
type: { type: 'string', enum: ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color'], default: 'text' },
|
|
588
649
|
label: { type: 'string' },
|
|
589
650
|
description: { type: 'string' },
|
|
590
651
|
required: { type: 'boolean', default: false },
|
|
@@ -614,6 +675,7 @@ export const SPEC_SCHEMA = {
|
|
|
614
675
|
max: { type: 'integer', default: 5, maximum: 10, description: 'scale only' },
|
|
615
676
|
minLabel: { type: 'string', description: 'scale only' },
|
|
616
677
|
maxLabel: { type: 'string', description: 'scale only' },
|
|
678
|
+
presets: { type: 'array', items: { type: 'string' }, description: 'color only: optional preset swatches (CSS colors) shown beside the native picker for one-click selection. The answer is returned as a hex string.' },
|
|
617
679
|
blocks: BLOCK_SCHEMA,
|
|
618
680
|
html: { type: 'string', description: 'Legacy: per-question custom HTML. Normalized into an html block prepended to this question\'s "blocks".' },
|
|
619
681
|
htmlFile: { type: 'string', description: 'Legacy: path to an HTML file (alternative to "html").' },
|
package/src/ui/app.js
CHANGED
|
@@ -698,6 +698,38 @@
|
|
|
698
698
|
return input;
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
// Normalize any CSS color to the #rrggbb the native <input type=color> needs.
|
|
702
|
+
function toHex6(c) {
|
|
703
|
+
const s = String(c || '').trim();
|
|
704
|
+
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
705
|
+
if (m) return '#' + m[1].toLowerCase();
|
|
706
|
+
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
707
|
+
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
708
|
+
return '#000000';
|
|
709
|
+
}
|
|
710
|
+
function controlColor(q) {
|
|
711
|
+
const wrap = el('div', { class: 'colorpick' });
|
|
712
|
+
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
713
|
+
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
714
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
715
|
+
swatch.value = toHex6(init || '#888888');
|
|
716
|
+
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
717
|
+
const set = (val) => { state.answers[q.id] = val; clearErr(q.id); scheduleSave(); };
|
|
718
|
+
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
719
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) swatch.value = toHex6(v); set(v); });
|
|
720
|
+
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
721
|
+
if (Array.isArray(q.presets) && q.presets.length) {
|
|
722
|
+
const presets = el('div', { class: 'colorpresets' });
|
|
723
|
+
for (const c of q.presets) {
|
|
724
|
+
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
725
|
+
b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
|
|
726
|
+
presets.append(b);
|
|
727
|
+
}
|
|
728
|
+
wrap.append(presets);
|
|
729
|
+
}
|
|
730
|
+
return wrap;
|
|
731
|
+
}
|
|
732
|
+
|
|
701
733
|
// ---------- render ----------
|
|
702
734
|
themeBtn = el('button', { class: 'theme-btn', type: 'button' }, '');
|
|
703
735
|
themeBtn.addEventListener('click', () => {
|
|
@@ -757,6 +789,7 @@
|
|
|
757
789
|
else if (q.type === 'multi') control.append(controlMulti(q));
|
|
758
790
|
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
759
791
|
else if (q.type === 'scale') control.append(controlScale(q));
|
|
792
|
+
else if (q.type === 'color') control.append(controlColor(q));
|
|
760
793
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
761
794
|
card.append(control);
|
|
762
795
|
if (q.note) {
|