@adia-ai/a2ui-runtime 0.6.6 → 0.6.8
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/stream.js +298 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog — @adia-ai/a2ui-runtime
|
|
2
2
|
|
|
3
|
+
## [0.6.8] — 2026-05-20
|
|
4
|
+
|
|
5
|
+
- Lockstep version bump. Headlining work in `@adia-ai/web-components`
|
|
6
|
+
(FB-55 `.camelCaseProp=${expr}` two-layer name resolution + FB-57
|
|
7
|
+
SVG/MathML namespace-aware `mount()` + USAGE.md docs cluster) and
|
|
8
|
+
`@adia-ai/web-modules` (claims-ui F-001 chat-shell.js barrel-import
|
|
9
|
+
fix + F-006 required-CSS callouts). No source changes in this package.
|
|
10
|
+
|
|
11
|
+
## [0.6.7] — 2026-05-19
|
|
12
|
+
|
|
13
|
+
- Lockstep version bump; no source changes.
|
|
14
|
+
|
|
3
15
|
## [0.6.6] — 2026-05-18
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-runtime",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "A2UI runtime \u2014 renderer, registry, streams, surface manifest, and wiring primitives for the A2UI (Agent-to-UI) protocol. Framework-agnostic; pairs with any A2UI-conformant component set.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/stream.js
CHANGED
|
@@ -125,8 +125,52 @@ export function mockStream(messages, delay = 0) {
|
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
127
|
* MCP stream adapter — connects to MCP server, yields A2UI messages.
|
|
128
|
+
*
|
|
129
|
+
* Dispatches by URL scheme:
|
|
130
|
+
* ws://, wss:// -> WebSocket transport (legacy / custom MCP-over-WS)
|
|
131
|
+
* http://, https:// -> MCP Streamable HTTP (spec 2025-11-25)
|
|
132
|
+
* relative path -> assume same-origin Streamable HTTP
|
|
128
133
|
*/
|
|
129
134
|
export function mcpStream(url, options = {}) {
|
|
135
|
+
const scheme = (url.split(':')[0] || '').toLowerCase();
|
|
136
|
+
if (scheme === 'ws' || scheme === 'wss') {
|
|
137
|
+
return mcpStreamWebSocket(url, options);
|
|
138
|
+
}
|
|
139
|
+
if (scheme === 'http' || scheme === 'https') {
|
|
140
|
+
return mcpStreamHttp(url, options);
|
|
141
|
+
}
|
|
142
|
+
// Default: relative path -> same-origin HTTP
|
|
143
|
+
return mcpStreamHttp(url, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Helper: extract A2UI messages from a JSON-RPC `result.content` array
|
|
148
|
+
* and push them into a queue/resolver pair.
|
|
149
|
+
*/
|
|
150
|
+
function _extractA2UIFromRpc(rpc, push) {
|
|
151
|
+
if (rpc.result?.content) {
|
|
152
|
+
for (const block of rpc.result.content) {
|
|
153
|
+
if (block.type === 'resource' && block.resource?.mimeType === 'application/json+a2ui') {
|
|
154
|
+
try {
|
|
155
|
+
const messages = JSON.parse(block.resource.text);
|
|
156
|
+
for (const msg of (Array.isArray(messages) ? messages : [messages])) push(msg);
|
|
157
|
+
} catch { /* malformed payload */ }
|
|
158
|
+
} else if (block.type === 'text') {
|
|
159
|
+
try {
|
|
160
|
+
const msg = JSON.parse(block.text);
|
|
161
|
+
if (msg.type && (msg.type.startsWith('create') || msg.type.startsWith('update') || msg.type.startsWith('delete'))) {
|
|
162
|
+
push(msg);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* not A2UI */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* MCP over WebSocket — original implementation, unchanged behavior.
|
|
172
|
+
*/
|
|
173
|
+
function mcpStreamWebSocket(url, options) {
|
|
130
174
|
return {
|
|
131
175
|
async *[Symbol.asyncIterator]() {
|
|
132
176
|
const { signal, catalog, onAction } = options;
|
|
@@ -153,30 +197,15 @@ export function mcpStream(url, options = {}) {
|
|
|
153
197
|
const queue = [];
|
|
154
198
|
let resolve = null;
|
|
155
199
|
let done = false;
|
|
200
|
+
const push = (msg) => {
|
|
201
|
+
if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
|
|
202
|
+
else queue.push(msg);
|
|
203
|
+
};
|
|
156
204
|
|
|
157
205
|
ws.onmessage = (e) => {
|
|
158
206
|
try {
|
|
159
207
|
const rpc = JSON.parse(e.data);
|
|
160
|
-
|
|
161
|
-
if (rpc.result?.content) {
|
|
162
|
-
for (const block of rpc.result.content) {
|
|
163
|
-
if (block.type === 'resource' && block.resource?.mimeType === 'application/json+a2ui') {
|
|
164
|
-
const messages = JSON.parse(block.resource.text);
|
|
165
|
-
for (const msg of (Array.isArray(messages) ? messages : [messages])) {
|
|
166
|
-
if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
|
|
167
|
-
else queue.push(msg);
|
|
168
|
-
}
|
|
169
|
-
} else if (block.type === 'text') {
|
|
170
|
-
try {
|
|
171
|
-
const msg = JSON.parse(block.text);
|
|
172
|
-
if (msg.type && (msg.type.startsWith('create') || msg.type.startsWith('update') || msg.type.startsWith('delete'))) {
|
|
173
|
-
if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
|
|
174
|
-
else queue.push(msg);
|
|
175
|
-
}
|
|
176
|
-
} catch { /* not A2UI */ }
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
208
|
+
_extractA2UIFromRpc(rpc, push);
|
|
180
209
|
|
|
181
210
|
if (rpc.method === 'a2ui/action' && onAction) {
|
|
182
211
|
onAction(rpc.params?.name, rpc.params?.arguments).then(result => {
|
|
@@ -205,6 +234,255 @@ export function mcpStream(url, options = {}) {
|
|
|
205
234
|
};
|
|
206
235
|
}
|
|
207
236
|
|
|
237
|
+
/**
|
|
238
|
+
* MCP over Streamable HTTP — per MCP spec 2025-11-25.
|
|
239
|
+
*
|
|
240
|
+
* Protocol:
|
|
241
|
+
* 1. POST <url> with JSON-RPC `initialize` request. Server replies with
|
|
242
|
+
* `mcp-session-id` header and either JSON or an SSE stream.
|
|
243
|
+
* 2. POST <url> with `notifications/initialized` (header set) to complete handshake.
|
|
244
|
+
* 3. GET <url> with `mcp-session-id` header (Accept: text/event-stream) opens
|
|
245
|
+
* a long-lived SSE stream of server->client JSON-RPC messages.
|
|
246
|
+
* 4. Each SSE `data:` event is a JSON-RPC message. We extract A2UI messages
|
|
247
|
+
* from `result.content` blocks the same way as the WS path.
|
|
248
|
+
* 5. If the server emits an `a2ui/action` request method, we POST a JSON-RPC
|
|
249
|
+
* response back to <url> with the session header.
|
|
250
|
+
*
|
|
251
|
+
* Note: this is a minimal client. Tools must be invoked separately by the
|
|
252
|
+
* consumer (this adapter is the *transport*, not a tool-call orchestrator).
|
|
253
|
+
* If the consumer needs feature-complete MCP semantics (resumability, request
|
|
254
|
+
* batching, cancellation tokens), use `@modelcontextprotocol/sdk`'s
|
|
255
|
+
* `StreamableHTTPClientTransport` directly.
|
|
256
|
+
*/
|
|
257
|
+
function mcpStreamHttp(url, options) {
|
|
258
|
+
return {
|
|
259
|
+
async *[Symbol.asyncIterator]() {
|
|
260
|
+
const { signal, catalog, onAction } = options;
|
|
261
|
+
|
|
262
|
+
const queue = [];
|
|
263
|
+
let resolve = null;
|
|
264
|
+
let done = false;
|
|
265
|
+
const push = (msg) => {
|
|
266
|
+
if (resolve) { const r = resolve; resolve = null; r({ value: msg, done: false }); }
|
|
267
|
+
else queue.push(msg);
|
|
268
|
+
};
|
|
269
|
+
const finish = () => {
|
|
270
|
+
done = true;
|
|
271
|
+
if (resolve) { const r = resolve; resolve = null; r({ value: undefined, done: true }); }
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// --- 1. initialize handshake ---
|
|
275
|
+
const initBody = {
|
|
276
|
+
jsonrpc: '2.0',
|
|
277
|
+
id: 1,
|
|
278
|
+
method: 'initialize',
|
|
279
|
+
params: {
|
|
280
|
+
protocolVersion: '2025-11-25',
|
|
281
|
+
capabilities: {},
|
|
282
|
+
clientInfo: { name: 'a2ui-runtime', version: '0.0.0' },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
let initResp;
|
|
287
|
+
try {
|
|
288
|
+
initResp = await fetch(url, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: {
|
|
291
|
+
'Content-Type': 'application/json',
|
|
292
|
+
'Accept': 'application/json, text/event-stream',
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify(initBody),
|
|
295
|
+
signal,
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.warn('A2UI MCP HTTP: initialize failed', err);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!initResp.ok) {
|
|
303
|
+
console.warn(`A2UI MCP HTTP: initialize returned ${initResp.status}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sessionId = initResp.headers.get('mcp-session-id');
|
|
308
|
+
if (!sessionId) {
|
|
309
|
+
console.warn('A2UI MCP HTTP: server did not return mcp-session-id header');
|
|
310
|
+
// Try to drain body so the connection can be reused
|
|
311
|
+
try { await initResp.text(); } catch { /* ignore */ }
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Drain initialize response body (we don't need its result for streaming)
|
|
316
|
+
try {
|
|
317
|
+
const ct = initResp.headers.get('content-type') || '';
|
|
318
|
+
if (ct.includes('text/event-stream')) {
|
|
319
|
+
// Read & discard
|
|
320
|
+
const reader = initResp.body.getReader();
|
|
321
|
+
// Read just enough to consume the initialize response then move on
|
|
322
|
+
const decoder = new TextDecoder();
|
|
323
|
+
let buf = '';
|
|
324
|
+
// eslint-disable-next-line no-constant-condition
|
|
325
|
+
while (true) {
|
|
326
|
+
const { done: rd, value } = await reader.read();
|
|
327
|
+
if (rd) break;
|
|
328
|
+
buf += decoder.decode(value, { stream: true });
|
|
329
|
+
if (buf.includes('\n\n')) break; // got at least one SSE event
|
|
330
|
+
}
|
|
331
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
332
|
+
} else {
|
|
333
|
+
await initResp.json().catch(() => null);
|
|
334
|
+
}
|
|
335
|
+
} catch { /* ignore */ }
|
|
336
|
+
|
|
337
|
+
// --- 2. send `notifications/initialized` ---
|
|
338
|
+
try {
|
|
339
|
+
await fetch(url, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: {
|
|
342
|
+
'Content-Type': 'application/json',
|
|
343
|
+
'Accept': 'application/json, text/event-stream',
|
|
344
|
+
'mcp-session-id': sessionId,
|
|
345
|
+
},
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
jsonrpc: '2.0',
|
|
348
|
+
method: 'notifications/initialized',
|
|
349
|
+
}),
|
|
350
|
+
signal,
|
|
351
|
+
}).then(r => r.text()).catch(() => null);
|
|
352
|
+
} catch { /* ignore */ }
|
|
353
|
+
|
|
354
|
+
// --- 3. optional: advertise catalog as a custom notification ---
|
|
355
|
+
if (catalog) {
|
|
356
|
+
const types = catalog instanceof Map ? [...catalog.keys()] : Object.keys(catalog);
|
|
357
|
+
try {
|
|
358
|
+
await fetch(url, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: {
|
|
361
|
+
'Content-Type': 'application/json',
|
|
362
|
+
'Accept': 'application/json, text/event-stream',
|
|
363
|
+
'mcp-session-id': sessionId,
|
|
364
|
+
},
|
|
365
|
+
body: JSON.stringify({
|
|
366
|
+
jsonrpc: '2.0',
|
|
367
|
+
method: 'a2ui/catalog',
|
|
368
|
+
params: { supportedTypes: types },
|
|
369
|
+
}),
|
|
370
|
+
signal,
|
|
371
|
+
}).then(r => r.text()).catch(() => null);
|
|
372
|
+
} catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Helper to POST a JSON-RPC response (for server->client requests)
|
|
376
|
+
const postRpc = (msg) => {
|
|
377
|
+
return fetch(url, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: {
|
|
380
|
+
'Content-Type': 'application/json',
|
|
381
|
+
'Accept': 'application/json, text/event-stream',
|
|
382
|
+
'mcp-session-id': sessionId,
|
|
383
|
+
},
|
|
384
|
+
body: JSON.stringify(msg),
|
|
385
|
+
signal,
|
|
386
|
+
}).then(r => r.text()).catch(() => null);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Handle an incoming JSON-RPC message
|
|
390
|
+
const handleRpc = (rpc) => {
|
|
391
|
+
_extractA2UIFromRpc(rpc, push);
|
|
392
|
+
if (rpc.method === 'a2ui/action' && onAction) {
|
|
393
|
+
Promise.resolve()
|
|
394
|
+
.then(() => onAction(rpc.params?.name, rpc.params?.arguments))
|
|
395
|
+
.then(result => postRpc({ jsonrpc: '2.0', id: rpc.id, result }))
|
|
396
|
+
.catch(err => postRpc({ jsonrpc: '2.0', id: rpc.id, error: { code: -1, message: err.message } }));
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// --- 4. open long-lived GET SSE stream ---
|
|
401
|
+
let getResp;
|
|
402
|
+
try {
|
|
403
|
+
getResp = await fetch(url, {
|
|
404
|
+
method: 'GET',
|
|
405
|
+
headers: {
|
|
406
|
+
'Accept': 'text/event-stream',
|
|
407
|
+
'mcp-session-id': sessionId,
|
|
408
|
+
},
|
|
409
|
+
signal,
|
|
410
|
+
});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
if (err?.name !== 'AbortError') console.warn('A2UI MCP HTTP: GET stream failed', err);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!getResp.ok || !getResp.body) {
|
|
417
|
+
console.warn(`A2UI MCP HTTP: GET stream returned ${getResp.status}`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Wire abort
|
|
422
|
+
if (signal) {
|
|
423
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Best-effort: DELETE session on stream close to free server-side state
|
|
427
|
+
const closeSession = () => {
|
|
428
|
+
try {
|
|
429
|
+
fetch(url, {
|
|
430
|
+
method: 'DELETE',
|
|
431
|
+
headers: { 'mcp-session-id': sessionId },
|
|
432
|
+
}).catch(() => null);
|
|
433
|
+
} catch { /* ignore */ }
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// SSE parser loop (running concurrently with the yield loop)
|
|
437
|
+
(async () => {
|
|
438
|
+
const reader = getResp.body.getReader();
|
|
439
|
+
const decoder = new TextDecoder();
|
|
440
|
+
let buffer = '';
|
|
441
|
+
try {
|
|
442
|
+
while (!done) {
|
|
443
|
+
const { done: rd, value } = await reader.read();
|
|
444
|
+
if (rd) break;
|
|
445
|
+
buffer += decoder.decode(value, { stream: true });
|
|
446
|
+
let idx;
|
|
447
|
+
// SSE events separated by blank lines
|
|
448
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
449
|
+
const rawEvent = buffer.slice(0, idx);
|
|
450
|
+
buffer = buffer.slice(idx + 2);
|
|
451
|
+
const dataLines = [];
|
|
452
|
+
for (const line of rawEvent.split('\n')) {
|
|
453
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
|
|
454
|
+
}
|
|
455
|
+
if (dataLines.length === 0) continue;
|
|
456
|
+
const data = dataLines.join('\n');
|
|
457
|
+
try {
|
|
458
|
+
const rpc = JSON.parse(data);
|
|
459
|
+
handleRpc(rpc);
|
|
460
|
+
} catch {
|
|
461
|
+
console.warn('A2UI MCP HTTP: invalid JSON-RPC SSE payload', data);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
if (err?.name !== 'AbortError') console.warn('A2UI MCP HTTP: stream read error', err);
|
|
467
|
+
} finally {
|
|
468
|
+
finish();
|
|
469
|
+
closeSession();
|
|
470
|
+
}
|
|
471
|
+
})();
|
|
472
|
+
|
|
473
|
+
while (!done || queue.length > 0) {
|
|
474
|
+
if (queue.length > 0) { yield queue.shift(); }
|
|
475
|
+
else if (done) { break; }
|
|
476
|
+
else {
|
|
477
|
+
const v = await new Promise(r => { resolve = r; });
|
|
478
|
+
if (v.done) break;
|
|
479
|
+
yield v.value;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
208
486
|
/**
|
|
209
487
|
* JSONL (newline-delimited JSON) stream adapter via fetch.
|
|
210
488
|
*/
|