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.
- package/cli-template/AGENTS.md +1 -1
- package/cli-template/FA-MCP-SDK-DOC/00-FA-MCP-SDK-index.md +19 -5
- package/cli-template/FA-MCP-SDK-DOC/02-1-tools-and-api.md +63 -0
- package/cli-template/FA-MCP-SDK-DOC/06-utilities.md +133 -5
- package/cli-template/FA-MCP-SDK-DOC/07-testing-and-operations.md +85 -0
- package/cli-template/FA-MCP-SDK-DOC/08-agent-tester-and-headless-api.md +284 -0
- package/cli-template/FA-MCP-SDK-DOC/10-mcp-apps.md +90 -0
- package/cli-template/examples/mcp-apps-canonical/README.md +62 -0
- package/cli-template/examples/mcp-apps-canonical/server.ts +95 -0
- package/cli-template/examples/mcp-apps-canonical/widget/index.html +147 -0
- package/cli-template/package.json +2 -1
- package/config/_local.yaml +6 -0
- package/config/custom-environment-variables.yaml +5 -0
- package/config/default.yaml +15 -0
- package/dist/core/_types_/config.d.ts +20 -0
- package/dist/core/_types_/config.d.ts.map +1 -1
- package/dist/core/_types_/types.d.ts +13 -0
- package/dist/core/_types_/types.d.ts.map +1 -1
- package/dist/core/agent-tester/agent-tester-router.d.ts.map +1 -1
- package/dist/core/agent-tester/agent-tester-router.js +79 -2
- package/dist/core/agent-tester/agent-tester-router.js.map +1 -1
- package/dist/core/agent-tester/services/TesterAgentService.d.ts +14 -0
- package/dist/core/agent-tester/services/TesterAgentService.d.ts.map +1 -1
- package/dist/core/agent-tester/services/TesterAgentService.js +101 -1
- package/dist/core/agent-tester/services/TesterAgentService.js.map +1 -1
- package/dist/core/agent-tester/services/TesterMcpClientService.d.ts +1 -0
- package/dist/core/agent-tester/services/TesterMcpClientService.d.ts.map +1 -1
- package/dist/core/agent-tester/services/TesterMcpClientService.js +46 -19
- package/dist/core/agent-tester/services/TesterMcpClientService.js.map +1 -1
- package/dist/core/agent-tester/services/mcp-apps-utils.d.ts +22 -0
- package/dist/core/agent-tester/services/mcp-apps-utils.d.ts.map +1 -0
- package/dist/core/agent-tester/services/mcp-apps-utils.js +77 -0
- package/dist/core/agent-tester/services/mcp-apps-utils.js.map +1 -0
- package/dist/core/agent-tester/types.d.ts +65 -0
- package/dist/core/agent-tester/types.d.ts.map +1 -1
- package/dist/core/index.d.ts +4 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +4 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/init-mcp-server.d.ts.map +1 -1
- package/dist/core/init-mcp-server.js +46 -5
- package/dist/core/init-mcp-server.js.map +1 -1
- package/dist/core/mcp/builtin-debug-tools.d.ts +41 -0
- package/dist/core/mcp/builtin-debug-tools.d.ts.map +1 -0
- package/dist/core/mcp/builtin-debug-tools.js +75 -0
- package/dist/core/mcp/builtin-debug-tools.js.map +1 -0
- package/dist/core/mcp/debug-trace.d.ts +26 -0
- package/dist/core/mcp/debug-trace.d.ts.map +1 -0
- package/dist/core/mcp/debug-trace.js +79 -0
- package/dist/core/mcp/debug-trace.js.map +1 -0
- package/dist/core/mcp/prompts.d.ts.map +1 -1
- package/dist/core/mcp/prompts.js +11 -0
- package/dist/core/mcp/prompts.js.map +1 -1
- package/dist/core/mcp/resources.d.ts.map +1 -1
- package/dist/core/mcp/resources.js +11 -0
- package/dist/core/mcp/resources.js.map +1 -1
- package/dist/core/utils/formatToolResult.d.ts +39 -0
- package/dist/core/utils/formatToolResult.d.ts.map +1 -1
- package/dist/core/utils/formatToolResult.js +58 -0
- package/dist/core/utils/formatToolResult.js.map +1 -1
- package/dist/core/utils/testing/debug-tool.d.ts +35 -0
- package/dist/core/utils/testing/debug-tool.d.ts.map +1 -0
- package/dist/core/utils/testing/debug-tool.js +146 -0
- package/dist/core/utils/testing/debug-tool.js.map +1 -0
- package/dist/core/web/server-http.d.ts.map +1 -1
- package/dist/core/web/server-http.js +26 -1
- package/dist/core/web/server-http.js.map +1 -1
- package/dist/core/web/static/agent-tester/index.html +55 -0
- package/dist/core/web/static/agent-tester/script.js +986 -9
- package/dist/core/web/static/agent-tester/styles.css +416 -0
- 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, '"');
|
|
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
|
+
'&': '&',
|
|
1170
|
+
'<': '<',
|
|
1171
|
+
'>': '>',
|
|
1172
|
+
'"': '"',
|
|
1173
|
+
"'": ''',
|
|
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', () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|