fa-mcp-sdk 0.4.124 → 0.4.134

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 (71) hide show
  1. package/cli-template/AGENTS.md +1 -1
  2. package/cli-template/FA-MCP-SDK-DOC/00-FA-MCP-SDK-index.md +19 -5
  3. package/cli-template/FA-MCP-SDK-DOC/02-1-tools-and-api.md +63 -0
  4. package/cli-template/FA-MCP-SDK-DOC/06-utilities.md +133 -5
  5. package/cli-template/FA-MCP-SDK-DOC/07-testing-and-operations.md +85 -0
  6. package/cli-template/FA-MCP-SDK-DOC/08-agent-tester-and-headless-api.md +284 -0
  7. package/cli-template/FA-MCP-SDK-DOC/10-mcp-apps.md +90 -0
  8. package/cli-template/examples/mcp-apps-canonical/README.md +62 -0
  9. package/cli-template/examples/mcp-apps-canonical/server.ts +95 -0
  10. package/cli-template/examples/mcp-apps-canonical/widget/index.html +147 -0
  11. package/cli-template/package.json +2 -1
  12. package/config/_local.yaml +6 -0
  13. package/config/custom-environment-variables.yaml +5 -0
  14. package/config/default.yaml +15 -0
  15. package/dist/core/_types_/config.d.ts +20 -0
  16. package/dist/core/_types_/config.d.ts.map +1 -1
  17. package/dist/core/_types_/types.d.ts +13 -0
  18. package/dist/core/_types_/types.d.ts.map +1 -1
  19. package/dist/core/agent-tester/agent-tester-router.d.ts.map +1 -1
  20. package/dist/core/agent-tester/agent-tester-router.js +79 -2
  21. package/dist/core/agent-tester/agent-tester-router.js.map +1 -1
  22. package/dist/core/agent-tester/services/TesterAgentService.d.ts +14 -0
  23. package/dist/core/agent-tester/services/TesterAgentService.d.ts.map +1 -1
  24. package/dist/core/agent-tester/services/TesterAgentService.js +101 -1
  25. package/dist/core/agent-tester/services/TesterAgentService.js.map +1 -1
  26. package/dist/core/agent-tester/services/TesterMcpClientService.d.ts +1 -0
  27. package/dist/core/agent-tester/services/TesterMcpClientService.d.ts.map +1 -1
  28. package/dist/core/agent-tester/services/TesterMcpClientService.js +46 -19
  29. package/dist/core/agent-tester/services/TesterMcpClientService.js.map +1 -1
  30. package/dist/core/agent-tester/services/mcp-apps-utils.d.ts +22 -0
  31. package/dist/core/agent-tester/services/mcp-apps-utils.d.ts.map +1 -0
  32. package/dist/core/agent-tester/services/mcp-apps-utils.js +77 -0
  33. package/dist/core/agent-tester/services/mcp-apps-utils.js.map +1 -0
  34. package/dist/core/agent-tester/types.d.ts +65 -0
  35. package/dist/core/agent-tester/types.d.ts.map +1 -1
  36. package/dist/core/index.d.ts +4 -1
  37. package/dist/core/index.d.ts.map +1 -1
  38. package/dist/core/index.js +4 -1
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/init-mcp-server.d.ts.map +1 -1
  41. package/dist/core/init-mcp-server.js +46 -5
  42. package/dist/core/init-mcp-server.js.map +1 -1
  43. package/dist/core/mcp/builtin-debug-tools.d.ts +41 -0
  44. package/dist/core/mcp/builtin-debug-tools.d.ts.map +1 -0
  45. package/dist/core/mcp/builtin-debug-tools.js +75 -0
  46. package/dist/core/mcp/builtin-debug-tools.js.map +1 -0
  47. package/dist/core/mcp/debug-trace.d.ts +26 -0
  48. package/dist/core/mcp/debug-trace.d.ts.map +1 -0
  49. package/dist/core/mcp/debug-trace.js +79 -0
  50. package/dist/core/mcp/debug-trace.js.map +1 -0
  51. package/dist/core/mcp/prompts.d.ts.map +1 -1
  52. package/dist/core/mcp/prompts.js +11 -0
  53. package/dist/core/mcp/prompts.js.map +1 -1
  54. package/dist/core/mcp/resources.d.ts.map +1 -1
  55. package/dist/core/mcp/resources.js +11 -0
  56. package/dist/core/mcp/resources.js.map +1 -1
  57. package/dist/core/utils/formatToolResult.d.ts +39 -0
  58. package/dist/core/utils/formatToolResult.d.ts.map +1 -1
  59. package/dist/core/utils/formatToolResult.js +58 -0
  60. package/dist/core/utils/formatToolResult.js.map +1 -1
  61. package/dist/core/utils/testing/debug-tool.d.ts +35 -0
  62. package/dist/core/utils/testing/debug-tool.d.ts.map +1 -0
  63. package/dist/core/utils/testing/debug-tool.js +146 -0
  64. package/dist/core/utils/testing/debug-tool.js.map +1 -0
  65. package/dist/core/web/server-http.d.ts.map +1 -1
  66. package/dist/core/web/server-http.js +26 -1
  67. package/dist/core/web/server-http.js.map +1 -1
  68. package/dist/core/web/static/agent-tester/index.html +55 -0
  69. package/dist/core/web/static/agent-tester/script.js +986 -9
  70. package/dist/core/web/static/agent-tester/styles.css +416 -0
  71. package/package.json +1 -1
@@ -192,6 +192,306 @@ class AuthManager {
192
192
  }
193
193
  }
194
194
 
195
+ /**
196
+ * Minimal MCP Apps host-side bridge for a single rendered widget. Owns one
197
+ * iframe, runs the JSON-RPC postMessage handshake (`ui/initialize` →
198
+ * `ui/notifications/initialized`), streams the captured tool I/O into the
199
+ * View, resizes the iframe on `ui/notifications/size-changed`, and proxies
200
+ * View→Host calls back through callbacks supplied by the host.
201
+ *
202
+ * Operates in desktop-style mode (proposal §6.1): single iframe on the same
203
+ * origin, CSP applied via `<meta http-equiv>` inside `srcdoc`. We accept the
204
+ * documented trade-off that meta-CSP is theoretically bypassable for a
205
+ * dev-tool.
206
+ */
207
+ class AppWidgetBridge {
208
+ constructor(appCall, hostContext, callbacks) {
209
+ this.appCall = appCall;
210
+ this.hostContext = hostContext;
211
+ this.callbacks = callbacks || {};
212
+ this.iframe = null;
213
+ this.state = 'idle';
214
+ this.viewProtocolVersion = null;
215
+ this.viewAppCapabilities = null;
216
+ this._listener = null;
217
+ this._pendingPostInit = false;
218
+ }
219
+
220
+ mount(container) {
221
+ this.iframe = this._createIframe();
222
+ container.appendChild(this.iframe);
223
+ this._listener = (e) => this._onMessage(e);
224
+ window.addEventListener('message', this._listener);
225
+ this.state = 'mounted';
226
+ }
227
+
228
+ destroy() {
229
+ if (this._listener) {
230
+ window.removeEventListener('message', this._listener);
231
+ this._listener = null;
232
+ }
233
+ if (this.iframe?.parentNode) {
234
+ this.iframe.parentNode.removeChild(this.iframe);
235
+ }
236
+ this.iframe = null;
237
+ this.state = 'destroyed';
238
+ }
239
+
240
+ setTheme(themeName) {
241
+ this.hostContext.theme = { name: themeName };
242
+ this._notify('ui/notifications/host-context-changed', { theme: this.hostContext.theme });
243
+ }
244
+
245
+ _createIframe() {
246
+ const ui = this.appCall.uiResource;
247
+ const iframe = document.createElement('iframe');
248
+ iframe.className = 'app-widget-iframe';
249
+ iframe.setAttribute('data-call-id', this.appCall.callId);
250
+ iframe.setAttribute('sandbox', 'allow-scripts allow-forms');
251
+ const allow = this._buildPermissionPolicy(ui?.meta?.permissions);
252
+ if (allow) {
253
+ iframe.setAttribute('allow', allow);
254
+ }
255
+ iframe.srcdoc = this._wrapHtml(ui.text, this._buildCspMeta(ui?.meta?.csp));
256
+ iframe.style.width = '100%';
257
+ iframe.style.minHeight = '180px';
258
+ iframe.style.border = ui?.meta?.prefersBorder ? '1px solid var(--border)' : 'none';
259
+ return iframe;
260
+ }
261
+
262
+ _wrapHtml(html, cspMeta) {
263
+ if (!cspMeta) {
264
+ return html;
265
+ }
266
+ // Inject CSP meta as early as possible so it applies to inline scripts.
267
+ if (/<head[^>]*>/i.test(html)) {
268
+ return html.replace(/<head([^>]*)>/i, `<head$1>\n${cspMeta}`);
269
+ }
270
+ if (/<html[^>]*>/i.test(html)) {
271
+ return html.replace(/<html([^>]*)>/i, `<html$1>\n<head>${cspMeta}</head>`);
272
+ }
273
+ return `<!DOCTYPE html><html><head>${cspMeta}</head><body>${html}</body></html>`;
274
+ }
275
+
276
+ _buildCspMeta(csp) {
277
+ const directives = [
278
+ "default-src 'none'",
279
+ `script-src ${this._cspList(['self', 'unsafe-inline', ...(csp?.resourceDomains || [])])}`,
280
+ `style-src ${this._cspList(['self', 'unsafe-inline', ...(csp?.resourceDomains || [])])}`,
281
+ `img-src ${this._cspList(['self', 'data:', ...(csp?.resourceDomains || [])])}`,
282
+ `media-src ${this._cspList(['self', 'data:', ...(csp?.resourceDomains || [])])}`,
283
+ `font-src ${this._cspList(['self', 'data:', ...(csp?.resourceDomains || [])])}`,
284
+ `connect-src ${this._cspList(['self', ...(csp?.connectDomains || [])])}`,
285
+ `frame-src ${this._cspList(csp?.frameDomains || [], "'none'")}`,
286
+ `base-uri ${this._cspList(csp?.baseUriDomains || ['self'])}`,
287
+ ];
288
+ return `<meta http-equiv="Content-Security-Policy" content="${this._escapeAttr(directives.join('; '))}">`;
289
+ }
290
+
291
+ _cspList(items, fallback) {
292
+ if (!items || items.length === 0) {
293
+ return fallback || "'self'";
294
+ }
295
+ return items.map((d) => (d === 'self' || d === 'unsafe-inline' || d === 'data:' ? `'${d}'` : d)).join(' ');
296
+ }
297
+
298
+ _buildPermissionPolicy(permissions) {
299
+ if (!permissions) {
300
+ return null;
301
+ }
302
+ const out = [];
303
+ if (permissions.camera) {
304
+ out.push('camera');
305
+ }
306
+ if (permissions.microphone) {
307
+ out.push('microphone');
308
+ }
309
+ if (permissions.geolocation) {
310
+ out.push('geolocation');
311
+ }
312
+ if (permissions.clipboardWrite) {
313
+ out.push('clipboard-write');
314
+ }
315
+ return out.join('; ');
316
+ }
317
+
318
+ _escapeAttr(s) {
319
+ return String(s).replace(/"/g, '&quot;');
320
+ }
321
+
322
+ _onMessage(event) {
323
+ if (!this.iframe || event.source !== this.iframe.contentWindow) {
324
+ return;
325
+ }
326
+ const msg = event.data;
327
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') {
328
+ return;
329
+ }
330
+ if (this.callbacks.onJsonRpcMessage) {
331
+ this.callbacks.onJsonRpcMessage('view→host', msg, this.appCall);
332
+ }
333
+ if (typeof msg.method === 'string') {
334
+ this._dispatch(msg);
335
+ }
336
+ }
337
+
338
+ _dispatch(msg) {
339
+ const m = msg.method;
340
+ switch (m) {
341
+ case 'ui/initialize':
342
+ this._respond(msg.id, this._buildInitializeResult(msg.params));
343
+ return;
344
+ case 'ui/notifications/initialized':
345
+ this.state = 'ready';
346
+ this._sendToolIO();
347
+ return;
348
+ case 'ui/notifications/size-changed':
349
+ this._handleSizeChange(msg.params);
350
+ return;
351
+ case 'ui/notifications/log':
352
+ case 'notifications/message':
353
+ if (this.callbacks.onLog) {
354
+ this.callbacks.onLog(msg.params, this.appCall);
355
+ }
356
+ return;
357
+ case 'ui/open-link':
358
+ this._handleOpenLink(msg);
359
+ return;
360
+ case 'ui/message':
361
+ this._handleViewMessage(msg);
362
+ return;
363
+ case 'ui/update-model-context':
364
+ this._handleUpdateModelContext(msg);
365
+ return;
366
+ case 'ui/request-display-mode':
367
+ this._respond(msg.id, { displayMode: 'inline' });
368
+ return;
369
+ case 'tools/call':
370
+ case 'ui/request-call-tool':
371
+ this._handleViewCallTool(msg);
372
+ return;
373
+ default:
374
+ // Notifications without a handler are silently accepted; requests must
375
+ // get a method-not-found error so the View's JSON-RPC stub resolves.
376
+ if (typeof msg.id !== 'undefined') {
377
+ this._respondError(msg.id, -32601, `Method not found: ${m}`);
378
+ }
379
+ }
380
+ }
381
+
382
+ _respond(id, result) {
383
+ if (typeof id === 'undefined' || !this.iframe?.contentWindow) {
384
+ return;
385
+ }
386
+ const reply = { jsonrpc: '2.0', id, result };
387
+ if (this.callbacks.onJsonRpcMessage) {
388
+ this.callbacks.onJsonRpcMessage('host→view', reply, this.appCall);
389
+ }
390
+ this.iframe.contentWindow.postMessage(reply, '*');
391
+ }
392
+
393
+ _respondError(id, code, message) {
394
+ if (typeof id === 'undefined' || !this.iframe?.contentWindow) {
395
+ return;
396
+ }
397
+ const reply = { jsonrpc: '2.0', id, error: { code, message } };
398
+ if (this.callbacks.onJsonRpcMessage) {
399
+ this.callbacks.onJsonRpcMessage('host→view', reply, this.appCall);
400
+ }
401
+ this.iframe.contentWindow.postMessage(reply, '*');
402
+ }
403
+
404
+ _notify(method, params) {
405
+ if (!this.iframe?.contentWindow) {
406
+ return;
407
+ }
408
+ const msg = { jsonrpc: '2.0', method, params };
409
+ if (this.callbacks.onJsonRpcMessage) {
410
+ this.callbacks.onJsonRpcMessage('host→view', msg, this.appCall);
411
+ }
412
+ this.iframe.contentWindow.postMessage(msg, '*');
413
+ }
414
+
415
+ _buildInitializeResult(reqParams) {
416
+ this.viewProtocolVersion = reqParams?.protocolVersion || null;
417
+ this.viewAppCapabilities = reqParams?.appCapabilities || null;
418
+ return {
419
+ protocolVersion: this.viewProtocolVersion || '2026-01-26',
420
+ hostInfo: { name: 'fa-mcp-sdk:agent-tester', version: this.hostContext.hostVersion || '1.0.0' },
421
+ hostCapabilities: {
422
+ openLinks: {},
423
+ logging: {},
424
+ serverTools: { listChanged: false },
425
+ sampling: {},
426
+ },
427
+ hostContext: {
428
+ theme: this.hostContext.theme || { name: 'light' },
429
+ displayMode: 'inline',
430
+ containerType: 'inline',
431
+ availableDisplayModes: ['inline'],
432
+ },
433
+ };
434
+ }
435
+
436
+ _sendToolIO() {
437
+ this._notify('ui/notifications/tool-input', {
438
+ toolName: this.appCall.toolName,
439
+ arguments: this.appCall.arguments || {},
440
+ });
441
+ this._notify('ui/notifications/tool-result', this.appCall.result);
442
+ }
443
+
444
+ _handleSizeChange(params) {
445
+ const h = Number(params?.height);
446
+ if (Number.isFinite(h) && h > 0 && this.iframe) {
447
+ this.iframe.style.height = Math.min(Math.max(h, 80), 1600) + 'px';
448
+ }
449
+ }
450
+
451
+ _handleOpenLink(msg) {
452
+ const url = msg.params?.url;
453
+ if (typeof url === 'string') {
454
+ try {
455
+ const u = new URL(url, window.location.href);
456
+ if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
457
+ window.open(u.href, '_blank', 'noopener,noreferrer');
458
+ }
459
+ } catch {
460
+ /* ignore invalid url */
461
+ }
462
+ }
463
+ this._respond(msg.id, {});
464
+ }
465
+
466
+ _handleViewMessage(msg) {
467
+ if (this.callbacks.onViewMessage) {
468
+ this.callbacks.onViewMessage(msg.params, this.appCall);
469
+ }
470
+ this._respond(msg.id, {});
471
+ }
472
+
473
+ _handleUpdateModelContext(msg) {
474
+ if (this.callbacks.onUpdateModelContext) {
475
+ this.callbacks.onUpdateModelContext(msg.params, this.appCall);
476
+ }
477
+ this._respond(msg.id, {});
478
+ }
479
+
480
+ async _handleViewCallTool(msg) {
481
+ if (!this.callbacks.onViewCallTool) {
482
+ // Stage 8 wires this up; until then refuse politely.
483
+ this._respondError(msg.id, -32601, 'View→Host tool calls not enabled');
484
+ return;
485
+ }
486
+ try {
487
+ const result = await this.callbacks.onViewCallTool(msg.params, this.appCall);
488
+ this._respond(msg.id, result);
489
+ } catch (e) {
490
+ this._respondError(msg.id, -32000, e?.message || 'Tool call failed');
491
+ }
492
+ }
493
+ }
494
+
195
495
  class McpAgentTester {
196
496
  constructor() {
197
497
  this.currentSessionId = null;
@@ -211,12 +511,19 @@ class McpAgentTester {
211
511
  this.messageFormats = {};
212
512
  this.messageTexts = {};
213
513
  this.defaultDisplayFormat = localStorage.getItem('agentTesterDefaultFormat') || 'HTML';
514
+ this.appMode = localStorage.getItem('agentTesterAppMode') === 'true';
515
+ this.activeAppWidgets = [];
516
+ this.maxLiveWidgets = 5;
517
+ this.uiMessageLog = [];
518
+ this.maxUiMessageLog = 500;
519
+ this._viewCallToolAllowed = false;
214
520
 
215
521
  this.mcpConfig = {
216
522
  url: null,
217
523
  transport: 'http',
218
524
  headers: {},
219
525
  name: null,
526
+ appMode: this.appMode,
220
527
  };
221
528
 
222
529
  this.initializeElements();
@@ -380,6 +687,504 @@ class McpAgentTester {
380
687
  }
381
688
  }
382
689
 
690
+ /**
691
+ * Toggle MCP Apps mode. Persists the flag, updates the active mcpConfig, and
692
+ * if the server is connected reconnects so the new capability set is sent on
693
+ * the next `initialize` handshake. All rendered widget iframes are cleared
694
+ * because their capability context just changed.
695
+ */
696
+ async handleAppModeToggle() {
697
+ const next = !!this.appModeToggle.checked;
698
+ this.appMode = next;
699
+ this.mcpConfig.appMode = next;
700
+ localStorage.setItem('agentTesterAppMode', next ? 'true' : 'false');
701
+
702
+ this.clearLiveAppWidgets();
703
+
704
+ if (this.currentServer && this.currentServer.isConnected) {
705
+ try {
706
+ await this.handleReconnect();
707
+ this.showToast(next ? 'MCP Apps mode: ON — reconnected' : 'MCP Apps mode: OFF — reconnected', 'success');
708
+ } catch (e) {
709
+ console.warn('Reconnect after appMode toggle failed:', e);
710
+ this.showToast('Reconnect failed: ' + (e?.message || e), 'error');
711
+ }
712
+ } else {
713
+ this.showToast(next ? 'MCP Apps mode enabled' : 'MCP Apps mode disabled', 'info');
714
+ }
715
+
716
+ this.refreshToolListAppIcons();
717
+ }
718
+
719
+ /**
720
+ * Spec §6.8: MCP Apps mode requires HTTP/SSE. Agent-tester currently only
721
+ * exposes HTTP/SSE transports in the UI, so this is a no-op stub kept for
722
+ * future STDIO transport support.
723
+ */
724
+ updateAppModeToggleAvailability() {
725
+ if (!this.appModeToggleLabel) {
726
+ return;
727
+ }
728
+ const transport = this.transportSelect?.value || 'http';
729
+ const supported = transport === 'http' || transport === 'sse';
730
+ this.appModeToggleLabel.classList.toggle('is-disabled', !supported);
731
+ if (this.appModeToggle) {
732
+ this.appModeToggle.disabled = !supported;
733
+ }
734
+ }
735
+
736
+ clearLiveAppWidgets() {
737
+ for (const entry of this.activeAppWidgets) {
738
+ try {
739
+ entry.bridge.destroy();
740
+ } catch (e) {
741
+ console.warn('Bridge destroy failed:', e);
742
+ }
743
+ if (entry.container?.parentNode) {
744
+ entry.container.parentNode.removeChild(entry.container);
745
+ }
746
+ }
747
+ this.activeAppWidgets = [];
748
+ }
749
+
750
+ /**
751
+ * Mount an `AppWidgetBridge` for a single appCall and return the container
752
+ * the message renderer should drop into the DOM. Honors the live-widget
753
+ * cap from proposal §6.2 by demoting the oldest widget to a static
754
+ * "poster" once the limit is exceeded.
755
+ */
756
+ renderAppWidget(appCall, messageId) {
757
+ const container = document.createElement('div');
758
+ container.className = 'app-widget-container';
759
+ container.dataset.callId = appCall.callId;
760
+ container.dataset.messageId = messageId;
761
+
762
+ const header = document.createElement('div');
763
+ header.className = 'app-widget-header';
764
+ header.innerHTML = `
765
+ <span class="material-icons-round app-widget-icon">grid_view</span>
766
+ <span class="app-widget-title">${this._escapeHtml(appCall.toolName)}</span>
767
+ <button type="button" class="app-widget-collapse btn-icon" title="Collapse">
768
+ <span class="material-icons-round">expand_less</span>
769
+ </button>
770
+ `;
771
+ container.appendChild(header);
772
+
773
+ const body = document.createElement('div');
774
+ body.className = 'app-widget-body';
775
+ container.appendChild(body);
776
+
777
+ const theme = document.documentElement.getAttribute('data-theme') || 'light';
778
+ const bridge = new AppWidgetBridge(
779
+ appCall,
780
+ { theme: { name: theme }, hostVersion: this.sdkVersion || '0.0.0' },
781
+ {
782
+ onJsonRpcMessage: (direction, msg) => this._logUiMessage(direction, msg, appCall),
783
+ onViewCallTool: (params, ac) => this._proxyViewCallTool(params, ac),
784
+ onLog: (params, ac) => this._logUiMessage('log', { method: 'notifications/message', params }, ac),
785
+ onViewMessage: (params, ac) => this._logUiMessage('msg', { method: 'ui/message', params }, ac),
786
+ onUpdateModelContext: (params, ac) =>
787
+ this._logUiMessage('ctx', { method: 'ui/update-model-context', params }, ac),
788
+ },
789
+ );
790
+ bridge.mount(body);
791
+
792
+ const entry = { callId: appCall.callId, messageId, bridge, container };
793
+ this.activeAppWidgets.push(entry);
794
+ this._enforceWidgetCap();
795
+
796
+ header.querySelector('.app-widget-collapse').addEventListener('click', () => {
797
+ const collapsed = container.classList.toggle('is-collapsed');
798
+ const icon = header.querySelector('.app-widget-collapse .material-icons-round');
799
+ if (icon) {
800
+ icon.textContent = collapsed ? 'expand_more' : 'expand_less';
801
+ }
802
+ });
803
+
804
+ return container;
805
+ }
806
+
807
+ _enforceWidgetCap() {
808
+ while (this.activeAppWidgets.length > this.maxLiveWidgets) {
809
+ const oldest = this.activeAppWidgets.shift();
810
+ if (!oldest) {
811
+ break;
812
+ }
813
+ try {
814
+ oldest.bridge.destroy();
815
+ } catch {
816
+ /* ignore */
817
+ }
818
+ if (oldest.container) {
819
+ oldest.container.classList.add('is-poster');
820
+ const body = oldest.container.querySelector('.app-widget-body');
821
+ if (body) {
822
+ body.innerHTML =
823
+ '<div class="app-widget-poster">Widget unloaded (live-widget cap reached). Reload page to re-render.</div>';
824
+ }
825
+ }
826
+ }
827
+ }
828
+
829
+ _logUiMessage(direction, msg, appCall) {
830
+ const entry = {
831
+ timestamp: new Date().toISOString(),
832
+ direction,
833
+ callId: appCall?.callId || null,
834
+ toolName: appCall?.toolName || null,
835
+ method: msg?.method || (typeof msg?.id !== 'undefined' ? 'response' : 'unknown'),
836
+ msg,
837
+ };
838
+ this.uiMessageLog.push(entry);
839
+ if (this.uiMessageLog.length > this.maxUiMessageLog) {
840
+ this.uiMessageLog.splice(0, this.uiMessageLog.length - this.maxUiMessageLog);
841
+ }
842
+ this._broadcastUiLogEntry(entry);
843
+ }
844
+
845
+ _broadcastUiLogEntry(_entry) {
846
+ if (this.activeTab === 'inspector') {
847
+ this.renderInspectorLog();
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Repaint the entire Inspector pane: app-tools list, UI resources, and the
853
+ * live `ui/*` JSON-RPC log. Called on tab activation and on the manual
854
+ * refresh button.
855
+ */
856
+ async refreshInspector() {
857
+ this.renderInspectorTools();
858
+ this.renderInspectorLog();
859
+ await this.refreshInspectorResources();
860
+ }
861
+
862
+ renderInspectorTools() {
863
+ if (!this.inspectorToolsList) {
864
+ return;
865
+ }
866
+ this.inspectorToolsList.innerHTML = '';
867
+ const tools = (this.currentServer?.isConnected && this.currentServer.tools) || [];
868
+ if (tools.length === 0) {
869
+ this.inspectorToolsList.innerHTML = '<li class="inspector-empty">No connected server</li>';
870
+ return;
871
+ }
872
+ for (const t of tools) {
873
+ const hasUi = this._toolHasUi(t);
874
+ const li = document.createElement('li');
875
+ li.className = hasUi ? 'inspector-tool has-ui' : 'inspector-tool';
876
+ li.innerHTML = `
877
+ <div class="inspector-tool-head">
878
+ <span class="material-icons-round inspector-tool-icon">${hasUi ? 'grid_view' : 'build'}</span>
879
+ <code>${this._escapeHtml(t.name)}</code>
880
+ ${hasUi ? '<span class="inspector-tool-flag">UI</span>' : ''}
881
+ </div>
882
+ <div class="inspector-tool-desc">${this._escapeHtml(t.description || '')}</div>
883
+ ${hasUi ? '<button type="button" class="btn btn-secondary inspector-launch">Launch widget</button>' : ''}
884
+ `;
885
+ const btn = li.querySelector('.inspector-launch');
886
+ if (btn) {
887
+ btn.addEventListener('click', () => this._launchInspectorWidget(t));
888
+ }
889
+ this.inspectorToolsList.appendChild(li);
890
+ }
891
+ }
892
+
893
+ async refreshInspectorResources() {
894
+ if (!this.inspectorResourcesList) {
895
+ return;
896
+ }
897
+ if (!this.currentServer?.isConnected) {
898
+ this.inspectorResourcesList.innerHTML = '<li class="inspector-empty">No connected server</li>';
899
+ return;
900
+ }
901
+ this.inspectorResourcesList.innerHTML = '<li class="inspector-empty">Loading…</li>';
902
+ try {
903
+ const resp = await apiFetch(
904
+ `${API_BASE}/api/mcp/ui-resources?serverName=${encodeURIComponent(this.currentServer.name)}`,
905
+ );
906
+ const data = await resp.json();
907
+ const list = Array.isArray(data.resources) ? data.resources : [];
908
+ if (list.length === 0) {
909
+ this.inspectorResourcesList.innerHTML = '<li class="inspector-empty">No UI resources registered</li>';
910
+ return;
911
+ }
912
+ this.inspectorResourcesList.innerHTML = '';
913
+ for (const r of list) {
914
+ const li = document.createElement('li');
915
+ li.className = 'inspector-resource';
916
+ li.innerHTML = `
917
+ <div class="inspector-tool-head">
918
+ <span class="material-icons-round inspector-tool-icon">code</span>
919
+ <code>${this._escapeHtml(r.uri || '')}</code>
920
+ </div>
921
+ <div class="inspector-tool-desc">${this._escapeHtml(r.name || r.description || '')}</div>
922
+ <div class="inspector-tool-mime">${this._escapeHtml(r.mimeType || '')}</div>
923
+ `;
924
+ this.inspectorResourcesList.appendChild(li);
925
+ }
926
+ } catch (e) {
927
+ this.inspectorResourcesList.innerHTML = `<li class="inspector-empty">Error: ${this._escapeHtml(e?.message || e)}</li>`;
928
+ }
929
+ }
930
+
931
+ renderInspectorLog() {
932
+ if (!this.inspectorLog) {
933
+ return;
934
+ }
935
+ const filter = this.inspectorLogFilter?.value || '';
936
+ const entries = filter
937
+ ? this.uiMessageLog.filter((e) => e.direction === filter || e.direction.startsWith(filter))
938
+ : this.uiMessageLog;
939
+ if (entries.length === 0) {
940
+ this.inspectorLog.textContent = '(no ui/* messages yet)';
941
+ return;
942
+ }
943
+ const lines = entries.slice(-200).map((e) => {
944
+ const tag = this._escapeHtml(e.direction);
945
+ const tool = this._escapeHtml(e.toolName || '-');
946
+ const method = this._escapeHtml(e.method || '-');
947
+ const shortMsg = JSON.stringify(e.msg).slice(0, 400);
948
+ return `[${e.timestamp.slice(11, 19)}] ${tag.padEnd(28)} ${tool} ${method} ${this._escapeHtml(shortMsg)}`;
949
+ });
950
+ this.inspectorLog.textContent = lines.join('\n');
951
+ this.inspectorLog.scrollTop = this.inspectorLog.scrollHeight;
952
+ }
953
+
954
+ /**
955
+ * Direct widget-only smoke test — invoke a tool without involving the LLM
956
+ * and mount the returned UI resource in a dedicated modal. Useful for
957
+ * iterating on widget HTML in isolation.
958
+ */
959
+ async _launchInspectorWidget(tool) {
960
+ if (!this.currentServer?.isConnected) {
961
+ this.showToast('Connect to an MCP server first', 'error');
962
+ return;
963
+ }
964
+ const args = prompt(`Arguments JSON for ${tool.name}:`, '{}');
965
+ if (args === null) {
966
+ return;
967
+ }
968
+ let parsed;
969
+ try {
970
+ parsed = args.trim() ? JSON.parse(args) : {};
971
+ } catch (e) {
972
+ this.showToast('Invalid JSON: ' + e.message, 'error');
973
+ return;
974
+ }
975
+
976
+ try {
977
+ const resp = await apiFetch(`${API_BASE}/api/mcp/call-tool`, {
978
+ method: 'POST',
979
+ headers: { 'Content-Type': 'application/json' },
980
+ body: JSON.stringify({
981
+ serverName: this.currentServer.name,
982
+ toolName: tool.name,
983
+ parameters: parsed,
984
+ }),
985
+ });
986
+ const data = await resp.json();
987
+ if (!resp.ok || !data.success) {
988
+ this.showToast('Tool call failed: ' + (data?.error || resp.status), 'error');
989
+ return;
990
+ }
991
+ if (!data.uiResource) {
992
+ this.showToast('Tool returned no UI resource', 'info');
993
+ return;
994
+ }
995
+ this._openWidgetModal(tool.name, parsed, data.result, data.uiResource);
996
+ } catch (e) {
997
+ this.showToast('Widget launch failed: ' + (e?.message || e), 'error');
998
+ }
999
+ }
1000
+
1001
+ _openWidgetModal(toolName, args, result, uiResource) {
1002
+ let modal = document.getElementById('inspectorWidgetModal');
1003
+ if (modal) {
1004
+ modal.remove();
1005
+ }
1006
+ modal = document.createElement('div');
1007
+ modal.id = 'inspectorWidgetModal';
1008
+ modal.className = 'inspector-modal-overlay';
1009
+ modal.innerHTML = `
1010
+ <div class="inspector-modal">
1011
+ <header>
1012
+ <span class="material-icons-round">grid_view</span>
1013
+ <strong>${this._escapeHtml(toolName)}</strong>
1014
+ <button type="button" class="btn-icon inspector-modal-close" title="Close">
1015
+ <span class="material-icons-round">close</span>
1016
+ </button>
1017
+ </header>
1018
+ <div class="inspector-modal-body"></div>
1019
+ </div>
1020
+ `;
1021
+ document.body.appendChild(modal);
1022
+ modal.querySelector('.inspector-modal-close').addEventListener('click', () => {
1023
+ if (this._inspectorBridge) {
1024
+ try {
1025
+ this._inspectorBridge.destroy();
1026
+ } catch {
1027
+ /* ignore */
1028
+ }
1029
+ this._inspectorBridge = null;
1030
+ }
1031
+ modal.remove();
1032
+ });
1033
+
1034
+ const appCall = {
1035
+ callId: `inspector-${Date.now()}`,
1036
+ toolName,
1037
+ arguments: args || {},
1038
+ result,
1039
+ uiResource,
1040
+ };
1041
+ const theme = document.documentElement.getAttribute('data-theme') || 'light';
1042
+ const bridge = new AppWidgetBridge(
1043
+ appCall,
1044
+ { theme: { name: theme }, hostVersion: this.sdkVersion || '0.0.0' },
1045
+ {
1046
+ onJsonRpcMessage: (direction, msg) => this._logUiMessage(direction, msg, appCall),
1047
+ onViewCallTool: (params, ac) => this._proxyViewCallTool(params, ac),
1048
+ onLog: (params, ac) => this._logUiMessage('log', { method: 'notifications/message', params }, ac),
1049
+ },
1050
+ );
1051
+ bridge.mount(modal.querySelector('.inspector-modal-body'));
1052
+ this._inspectorBridge = bridge;
1053
+ }
1054
+
1055
+ /**
1056
+ * Proxy View → Host `tools/call`. Per proposal §6.4, the first view-initiated
1057
+ * call in a session pops a confirm modal; the user MAY persist consent for
1058
+ * the rest of the session via `sessionStorage`. All calls are logged as
1059
+ * `view→host:call-tool` for the App Inspector.
1060
+ */
1061
+ async _proxyViewCallTool(params, appCall) {
1062
+ const toolName = params?.name || params?.toolName;
1063
+ if (!toolName || typeof toolName !== 'string') {
1064
+ throw new Error('Missing tool name');
1065
+ }
1066
+ const args = params?.arguments || params?.args || {};
1067
+
1068
+ this._logUiMessage(
1069
+ 'view→host:call-tool',
1070
+ { method: 'tools/call', params: { name: toolName, arguments: args } },
1071
+ appCall,
1072
+ );
1073
+
1074
+ if (!(await this._confirmViewCallTool(toolName, appCall))) {
1075
+ throw new Error('User denied widget tool call');
1076
+ }
1077
+
1078
+ if (!this.currentServer || !this.currentServer.isConnected) {
1079
+ throw new Error('No MCP server connected');
1080
+ }
1081
+
1082
+ const response = await apiFetch(`${API_BASE}/api/mcp/call-tool`, {
1083
+ method: 'POST',
1084
+ headers: { 'Content-Type': 'application/json' },
1085
+ body: JSON.stringify({
1086
+ serverName: this.currentServer.name,
1087
+ toolName,
1088
+ parameters: args,
1089
+ }),
1090
+ });
1091
+
1092
+ const data = await response.json();
1093
+ if (!response.ok || !data.success) {
1094
+ const errMsg = data?.error || `HTTP ${response.status}`;
1095
+ this._logUiMessage(
1096
+ 'host→view:call-tool-error',
1097
+ { method: 'tools/call', error: errMsg, params: { name: toolName } },
1098
+ appCall,
1099
+ );
1100
+ throw new Error(errMsg);
1101
+ }
1102
+
1103
+ this._logUiMessage(
1104
+ 'host→view:call-tool-result',
1105
+ { method: 'tools/call', params: { name: toolName }, result: data.result },
1106
+ appCall,
1107
+ );
1108
+ return data.result;
1109
+ }
1110
+
1111
+ async _confirmViewCallTool(toolName, appCall) {
1112
+ const SESSION_KEY = 'agentTesterAppWidgetConsent';
1113
+ try {
1114
+ if (sessionStorage.getItem(SESSION_KEY) === 'allow') {
1115
+ return true;
1116
+ }
1117
+ } catch {
1118
+ /* sessionStorage may be unavailable in private mode */
1119
+ }
1120
+
1121
+ return new Promise((resolve) => {
1122
+ const overlay = document.createElement('div');
1123
+ overlay.className = 'app-widget-confirm-overlay';
1124
+ overlay.innerHTML = `
1125
+ <div class="app-widget-confirm">
1126
+ <h3>Widget action requested</h3>
1127
+ <p>Widget for <code>${this._escapeHtml(appCall.toolName)}</code> wants to call tool
1128
+ <strong>${this._escapeHtml(toolName)}</strong> on the connected MCP server.</p>
1129
+ <label class="app-widget-remember">
1130
+ <input type="checkbox" id="appWidgetConsentRemember">
1131
+ <span>Don't ask again in this session</span>
1132
+ </label>
1133
+ <div class="app-widget-confirm-actions">
1134
+ <button type="button" class="btn" data-action="deny">Deny</button>
1135
+ <button type="button" class="btn btn-primary" data-action="allow">Allow</button>
1136
+ </div>
1137
+ </div>
1138
+ `;
1139
+ document.body.appendChild(overlay);
1140
+
1141
+ const remember = overlay.querySelector('#appWidgetConsentRemember');
1142
+ const finish = (allowed) => {
1143
+ if (allowed && remember?.checked) {
1144
+ try {
1145
+ sessionStorage.setItem(SESSION_KEY, 'allow');
1146
+ } catch {
1147
+ /* ignore */
1148
+ }
1149
+ }
1150
+ overlay.remove();
1151
+ resolve(allowed);
1152
+ };
1153
+ overlay.addEventListener('click', (e) => {
1154
+ const action = e.target?.closest?.('button')?.dataset?.action;
1155
+ if (action === 'allow') {
1156
+ finish(true);
1157
+ } else if (action === 'deny' || e.target === overlay) {
1158
+ finish(false);
1159
+ }
1160
+ });
1161
+ });
1162
+ }
1163
+
1164
+ _escapeHtml(s) {
1165
+ return String(s).replace(
1166
+ /[&<>"']/g,
1167
+ (c) =>
1168
+ ({
1169
+ '&': '&amp;',
1170
+ '<': '&lt;',
1171
+ '>': '&gt;',
1172
+ '"': '&quot;',
1173
+ "'": '&#39;',
1174
+ })[c],
1175
+ );
1176
+ }
1177
+
1178
+ /**
1179
+ * Placeholder hook — populated in the Tool Tester stage with logic that adds
1180
+ * a "has UI" icon to app-tools in the dropdown when appMode is ON.
1181
+ */
1182
+ refreshToolListAppIcons() {
1183
+ if (this.ttToolSelect && Array.isArray(this.ttTools)) {
1184
+ this.refreshToolList();
1185
+ }
1186
+ }
1187
+
383
1188
  renderMessageContent(element, text, format) {
384
1189
  if (format === 'HTML') {
385
1190
  element.innerHTML = this.sanitizeHtml(text).trim();
@@ -453,6 +1258,16 @@ class McpAgentTester {
453
1258
 
454
1259
  this.themeToggle = document.getElementById('themeToggle');
455
1260
  this.defaultFormatSelect = document.getElementById('defaultDisplayFormat');
1261
+ this.appModeToggle = document.getElementById('appModeToggle');
1262
+ this.appModeToggleLabel = document.getElementById('appModeToggleLabel');
1263
+
1264
+ // App Inspector tab elements
1265
+ this.inspectorToolsList = document.getElementById('inspectorToolsList');
1266
+ this.inspectorResourcesList = document.getElementById('inspectorResourcesList');
1267
+ this.inspectorLog = document.getElementById('inspectorLog');
1268
+ this.inspectorLogFilter = document.getElementById('inspectorLogFilter');
1269
+ this.inspectorLogClear = document.getElementById('inspectorLogClear');
1270
+ this.inspectorRefreshBtn = document.getElementById('inspectorRefreshBtn');
456
1271
 
457
1272
  // Tool Tester tab elements
458
1273
  this.tabsBar = document.querySelector('.tabs-bar');
@@ -488,10 +1303,19 @@ class McpAgentTester {
488
1303
  this.defaultFormatSelect.addEventListener('change', () => this.handleDefaultFormatChange());
489
1304
  }
490
1305
 
1306
+ if (this.appModeToggle) {
1307
+ this.appModeToggle.checked = this.appMode;
1308
+ this.appModeToggle.addEventListener('change', () => this.handleAppModeToggle());
1309
+ this.updateAppModeToggleAvailability();
1310
+ }
1311
+
491
1312
  this.mcpConnectionForm.addEventListener('submit', (e) => this.handleMcpConnection(e));
492
1313
 
493
1314
  this.serverUrlInput.addEventListener('input', () => this.handleServerUrlChange());
494
- this.transportSelect.addEventListener('change', () => this.saveFormValuesToStorage());
1315
+ this.transportSelect.addEventListener('change', () => {
1316
+ this.saveFormValuesToStorage();
1317
+ this.updateAppModeToggleAvailability();
1318
+ });
495
1319
 
496
1320
  this.serverUrlDropdown.addEventListener('click', (e) => this.toggleUrlDropdown(e));
497
1321
  document.addEventListener('click', (e) => this.handleClickOutside(e));
@@ -597,6 +1421,19 @@ class McpAgentTester {
597
1421
  if (this.ttResponseTextView) {
598
1422
  this.ttResponseTextView.addEventListener('click', () => this.toggleResponseTextMode());
599
1423
  }
1424
+
1425
+ if (this.inspectorRefreshBtn) {
1426
+ this.inspectorRefreshBtn.addEventListener('click', () => this.refreshInspector());
1427
+ }
1428
+ if (this.inspectorLogClear) {
1429
+ this.inspectorLogClear.addEventListener('click', () => {
1430
+ this.uiMessageLog = [];
1431
+ this.renderInspectorLog();
1432
+ });
1433
+ }
1434
+ if (this.inspectorLogFilter) {
1435
+ this.inspectorLogFilter.addEventListener('change', () => this.renderInspectorLog());
1436
+ }
600
1437
  }
601
1438
 
602
1439
  initTheme() {
@@ -617,6 +1454,13 @@ class McpAgentTester {
617
1454
 
618
1455
  applyTheme(theme) {
619
1456
  document.documentElement.setAttribute('data-theme', theme);
1457
+ for (const entry of this.activeAppWidgets || []) {
1458
+ try {
1459
+ entry.bridge.setTheme(theme);
1460
+ } catch {
1461
+ /* widget already torn down */
1462
+ }
1463
+ }
620
1464
  if (this.themeToggle) {
621
1465
  const icon = this.themeToggle.querySelector('.material-icons-round');
622
1466
  if (icon) {
@@ -716,6 +1560,7 @@ class McpAgentTester {
716
1560
  url: serverUrl,
717
1561
  transport: transport,
718
1562
  headers: this.getHeadersFromForm(),
1563
+ appMode: this.appMode,
719
1564
  };
720
1565
 
721
1566
  this.showLoading('Auto-connecting to MCP server...');
@@ -742,6 +1587,7 @@ class McpAgentTester {
742
1587
  transport: transport,
743
1588
  headers: this.getHeadersFromForm(),
744
1589
  name: serverName,
1590
+ appMode: this.appMode,
745
1591
  };
746
1592
 
747
1593
  if (result.config && result.config.agentPrompt) {
@@ -810,6 +1656,7 @@ class McpAgentTester {
810
1656
  url: serverUrl,
811
1657
  transport: transport,
812
1658
  headers: this.getHeadersFromForm(),
1659
+ appMode: this.appMode,
813
1660
  };
814
1661
 
815
1662
  this.showLoading('Connecting to MCP server...');
@@ -836,6 +1683,7 @@ class McpAgentTester {
836
1683
  transport: transport,
837
1684
  headers: this.getHeadersFromForm(),
838
1685
  name: serverName,
1686
+ appMode: this.appMode,
839
1687
  };
840
1688
 
841
1689
  if (result.config && result.config.agentPrompt) {
@@ -1329,6 +2177,7 @@ class McpAgentTester {
1329
2177
  transport: 'http',
1330
2178
  headers: {},
1331
2179
  name: null,
2180
+ appMode: this.appMode,
1332
2181
  };
1333
2182
  this.originalAgentPrompt = null;
1334
2183
  this.updateResetPromptButton();
@@ -1416,6 +2265,7 @@ class McpAgentTester {
1416
2265
  transport: 'http',
1417
2266
  headers: {},
1418
2267
  name: null,
2268
+ appMode: this.appMode,
1419
2269
  };
1420
2270
  this.originalAgentPrompt = null;
1421
2271
  this.updateResetPromptButton();
@@ -1440,6 +2290,7 @@ class McpAgentTester {
1440
2290
  url: this.currentServer.url,
1441
2291
  transport: this.currentServer.transport || 'http',
1442
2292
  headers: this.currentServer.headers || {},
2293
+ appMode: this.appMode,
1443
2294
  };
1444
2295
 
1445
2296
  this.showLoading('Reconnecting to MCP server...');
@@ -1544,9 +2395,11 @@ class McpAgentTester {
1544
2395
  transport: this.mcpConfig.transport,
1545
2396
  headers: this.mcpConfig.headers,
1546
2397
  name: this.mcpConfig.name,
2398
+ appMode: this.appMode,
1547
2399
  }
1548
2400
  : undefined,
1549
2401
  modelConfig: modelConfig,
2402
+ appMode: this.appMode,
1550
2403
  };
1551
2404
 
1552
2405
  const response = await apiFetch(`${API_BASE}/api/chat/message`, {
@@ -1563,7 +2416,14 @@ class McpAgentTester {
1563
2416
 
1564
2417
  this.currentSessionId = result.sessionId;
1565
2418
 
1566
- this.addMessage(result.message, 'assistant', result.metadata);
2419
+ // appCalls[] arrives only in MCP Apps mode. Forward to the renderer
2420
+ // alongside the assistant message so the widget shows up next to its
2421
+ // text body.
2422
+ const metadata = result.metadata || {};
2423
+ if (Array.isArray(result.appCalls) && result.appCalls.length > 0) {
2424
+ metadata.appCalls = result.appCalls;
2425
+ }
2426
+ this.addMessage(result.message, 'assistant', metadata);
1567
2427
  } catch (error) {
1568
2428
  console.error('Send message error:', error);
1569
2429
  this.addMessage(`Error: ${error.message}`, 'assistant', { error: true });
@@ -1635,6 +2495,18 @@ class McpAgentTester {
1635
2495
  responseTime.innerHTML = `<small class="a-info">Response time: ${metadata.response_time}ms</small>`;
1636
2496
  content.appendChild(responseTime);
1637
2497
  }
2498
+
2499
+ if (Array.isArray(metadata.appCalls)) {
2500
+ for (const appCall of metadata.appCalls) {
2501
+ if (!appCall?.uiResource) {
2502
+ continue;
2503
+ }
2504
+ const widgetContainer = this.renderAppWidget(appCall, messageId);
2505
+ if (widgetContainer) {
2506
+ content.appendChild(widgetContainer);
2507
+ }
2508
+ }
2509
+ }
1638
2510
  }
1639
2511
 
1640
2512
  messageDiv.appendChild(avatar);
@@ -1665,6 +2537,7 @@ class McpAgentTester {
1665
2537
  }
1666
2538
 
1667
2539
  clearChat() {
2540
+ this.clearLiveAppWidgets();
1668
2541
  const welcomeMessage = this.chatMessages.querySelector('.message.welcome');
1669
2542
  this.chatMessages.innerHTML = '';
1670
2543
  if (welcomeMessage) {
@@ -2250,17 +3123,33 @@ class McpAgentTester {
2250
3123
  appEl.setAttribute('data-active-tab', tabName);
2251
3124
  }
2252
3125
 
3126
+ const inspectorPane = document.getElementById('tabPaneInspector');
3127
+ const hideAll = () => {
3128
+ this.tabPaneChat.style.display = 'none';
3129
+ this.tabPaneChat.classList.remove('active');
3130
+ this.tabPaneToolTester.style.display = 'none';
3131
+ this.tabPaneToolTester.classList.remove('active');
3132
+ if (inspectorPane) {
3133
+ inspectorPane.style.display = 'none';
3134
+ inspectorPane.classList.remove('active');
3135
+ }
3136
+ };
2253
3137
  if (tabName === 'chat') {
3138
+ hideAll();
2254
3139
  this.tabPaneChat.style.display = '';
2255
3140
  this.tabPaneChat.classList.add('active');
2256
- this.tabPaneToolTester.style.display = 'none';
2257
- this.tabPaneToolTester.classList.remove('active');
2258
3141
  } else if (tabName === 'tool-tester') {
2259
- this.tabPaneChat.style.display = 'none';
2260
- this.tabPaneChat.classList.remove('active');
3142
+ hideAll();
2261
3143
  this.tabPaneToolTester.style.display = '';
2262
3144
  this.tabPaneToolTester.classList.add('active');
2263
3145
  this.refreshToolList();
3146
+ } else if (tabName === 'inspector') {
3147
+ hideAll();
3148
+ if (inspectorPane) {
3149
+ inspectorPane.style.display = '';
3150
+ inspectorPane.classList.add('active');
3151
+ }
3152
+ this.refreshInspector();
2264
3153
  }
2265
3154
  }
2266
3155
 
@@ -2279,7 +3168,13 @@ class McpAgentTester {
2279
3168
  tools.forEach((tool) => {
2280
3169
  const opt = document.createElement('option');
2281
3170
  opt.value = tool.name;
2282
- opt.textContent = tool.name;
3171
+ const isApp = this._toolHasUi(tool);
3172
+ // Prefix with a small marker so the LLM can't disambiguate, but a human
3173
+ // reading the dropdown immediately sees which tools ship a widget.
3174
+ opt.textContent = this.appMode && isApp ? `🖼 ${tool.name}` : tool.name;
3175
+ if (isApp) {
3176
+ opt.dataset.hasUi = 'true';
3177
+ }
2283
3178
  this.ttToolSelect.appendChild(opt);
2284
3179
  });
2285
3180
 
@@ -2288,6 +3183,11 @@ class McpAgentTester {
2288
3183
  this.handleToolSelectionChange();
2289
3184
  }
2290
3185
 
3186
+ _toolHasUi(tool) {
3187
+ const uri = tool?._meta?.ui?.resourceUri ?? tool?._meta?.['ui/resourceUri'];
3188
+ return typeof uri === 'string' && uri.length > 0;
3189
+ }
3190
+
2291
3191
  getSelectedTool() {
2292
3192
  const name = this.ttToolSelect?.value;
2293
3193
  if (!name || !Array.isArray(this.ttTools)) {
@@ -2643,7 +3543,7 @@ class McpAgentTester {
2643
3543
 
2644
3544
  this.ttRequestStatus.textContent = `Success in ${data.durationMs ?? elapsedMs} ms`;
2645
3545
  this.ttRequestStatus.className = 'tt-status tt-status-success';
2646
- this.renderToolResponse(data.result, false);
3546
+ this.renderToolResponse(data.result, false, data.uiResource);
2647
3547
  } catch (error) {
2648
3548
  const elapsedMs = Math.round(performance.now() - startedAt);
2649
3549
  this.ttRequestStatus.textContent = `Error in ${elapsedMs} ms`;
@@ -2654,13 +3554,89 @@ class McpAgentTester {
2654
3554
  }
2655
3555
  }
2656
3556
 
2657
- renderToolResponse(result, isError) {
3557
+ renderToolResponse(result, isError, uiResource) {
2658
3558
  this.ttLastResult = result;
2659
3559
  this.ttResponseContent.classList.toggle('tt-response-error', !!isError);
2660
3560
  if (isError) {
2661
3561
  this.ttResponseTextMode = false;
2662
3562
  }
2663
3563
  this.renderToolResponseBody();
3564
+ this.renderToolResponseWidget(uiResource);
3565
+ }
3566
+
3567
+ /**
3568
+ * Mount or tear down the split-view widget panel next to the raw JSON
3569
+ * response. Only renders when MCP Apps mode is ON and the server actually
3570
+ * returned a UI resource (either embedded or via `_meta.ui.resourceUri`).
3571
+ */
3572
+ renderToolResponseWidget(uiResource) {
3573
+ let panel = document.getElementById('ttUiWidgetPanel');
3574
+ if (this._ttUiBridge) {
3575
+ try {
3576
+ this._ttUiBridge.destroy();
3577
+ } catch {
3578
+ /* ignore */
3579
+ }
3580
+ this._ttUiBridge = null;
3581
+ }
3582
+ if (!this.appMode || !uiResource) {
3583
+ if (panel) {
3584
+ panel.remove();
3585
+ }
3586
+ this.ttLayout?.classList.remove('tt-has-ui');
3587
+ return;
3588
+ }
3589
+
3590
+ if (!panel) {
3591
+ panel = document.createElement('section');
3592
+ panel.id = 'ttUiWidgetPanel';
3593
+ panel.className = 'tt-panel tt-ui-panel';
3594
+ panel.setAttribute('data-testid', 'at-tt-ui-panel');
3595
+ panel.innerHTML = `
3596
+ <header class="tt-panel-header">
3597
+ <h3>UI Widget</h3>
3598
+ <span class="tt-ui-badge">MCP Apps</span>
3599
+ </header>
3600
+ <div class="tt-ui-body"></div>
3601
+ `;
3602
+ this.ttLayout.appendChild(panel);
3603
+ }
3604
+
3605
+ const body = panel.querySelector('.tt-ui-body');
3606
+ body.innerHTML = '';
3607
+
3608
+ const tool = this.getSelectedTool();
3609
+ const theme = document.documentElement.getAttribute('data-theme') || 'light';
3610
+ const appCall = {
3611
+ callId: `tt-${Date.now()}`,
3612
+ toolName: tool?.name || 'unknown',
3613
+ arguments: this._safeParseJson(this.ttRequestJson?.value) || {},
3614
+ result: this.ttLastResult,
3615
+ uiResource,
3616
+ };
3617
+ const bridge = new AppWidgetBridge(
3618
+ appCall,
3619
+ { theme: { name: theme }, hostVersion: this.sdkVersion || '0.0.0' },
3620
+ {
3621
+ onJsonRpcMessage: (direction, msg) => this._logUiMessage(direction, msg, appCall),
3622
+ onViewCallTool: (params, ac) => this._proxyViewCallTool(params, ac),
3623
+ onLog: (params, ac) => this._logUiMessage('log', { method: 'notifications/message', params }, ac),
3624
+ },
3625
+ );
3626
+ bridge.mount(body);
3627
+ this._ttUiBridge = bridge;
3628
+ this.ttLayout.classList.add('tt-has-ui');
3629
+ }
3630
+
3631
+ _safeParseJson(s) {
3632
+ if (!s) {
3633
+ return null;
3634
+ }
3635
+ try {
3636
+ return JSON.parse(s);
3637
+ } catch {
3638
+ return null;
3639
+ }
2664
3640
  }
2665
3641
 
2666
3642
  renderToolResponseBody() {
@@ -2747,6 +3723,7 @@ class McpAgentTester {
2747
3723
  }
2748
3724
  this.ttResponseContent.innerHTML =
2749
3725
  '<span class="tt-placeholder">No response yet. Connect to a server, select a tool, and send a request.</span>';
3726
+ this.renderToolResponseWidget(null);
2750
3727
  this.ttRequestStatus.textContent = '';
2751
3728
  this.ttRequestStatus.className = 'tt-status';
2752
3729
  }