@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. 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.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
  */