@@ -14,6 +14,7 @@ import { makeDiscoveryDriver } from "../drivers/discovery.js";
1414import { SkillManager } from "../skills/manager.js" ;
1515import { PermissionManager } from "../permissions/manager.js" ;
1616import { MCPManager } from "../mcp/manager.js" ;
17+ import type { MCPClientEvent } from "../mcp/types.js" ;
1718import { AppHostManager } from "../mcp/app/host.js" ;
1819import { inferLayout } from "../ui/mdx/inference.js" ;
1920import { TuiApp } from "../ui/tui-app.js" ;
@@ -32,6 +33,7 @@ export class Harness {
3233 private config : HarnessConfig ;
3334 private tui ! : TuiApp ;
3435 private baseSystemPrompt = "" ;
36+ private lastMcpProgress = new Map < string , { progress ?: number ; total ?: number ; message ?: string } > ( ) ;
3537
3638 constructor ( config : HarnessConfig ) {
3739 this . config = config ;
@@ -146,6 +148,7 @@ export class Harness {
146148 this . mcpManager = new MCPManager ( this . config . mcp ) ;
147149 this . tui . setMcpManager ( this . mcpManager ) ;
148150 await this . mcpManager . initialize ( ) ;
151+ this . mcpManager . onEvent ( ( event ) => this . handleMcpEvent ( event ) ) ;
149152 await this . mcpManager . registerDrivers ( this . driverRegistry ) ;
150153
151154 if ( this . appHostManager ) {
@@ -309,18 +312,45 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
309312
310313 private registeredApps = new Map < string , string > ( ) ;
311314
315+ private getToolResultDetails ( toolResult : unknown ) : Record < string , unknown > | undefined {
316+ if ( ! toolResult || typeof toolResult !== "object" ) return undefined ;
317+ const details = ( toolResult as Record < string , unknown > ) . details ;
318+ return details && typeof details === "object" ? details as Record < string , unknown > : undefined ;
319+ }
320+
321+ private getToolPayload ( toolResult : unknown ) : unknown {
322+ const details = this . getToolResultDetails ( toolResult ) ;
323+ return details ?. mcpResult ?? toolResult ;
324+ }
325+
326+ private getStructuredContent ( toolResult : unknown ) : Record < string , unknown > | undefined {
327+ const payload = this . getToolPayload ( toolResult ) ;
328+ if ( ! payload || typeof payload !== "object" ) return undefined ;
329+ const structuredContent = ( payload as Record < string , unknown > ) . structuredContent ;
330+ return structuredContent && typeof structuredContent === "object"
331+ ? structuredContent as Record < string , unknown >
332+ : undefined ;
333+ }
334+
335+ private getEffectiveToolError ( toolResult : unknown , isError : boolean ) : boolean {
336+ if ( isError ) return true ;
337+ const details = this . getToolResultDetails ( toolResult ) ;
338+ return Boolean ( details ?. error ) ;
339+ }
340+
312341 private checkAndRegisterApp ( toolName : string , toolResult ?: unknown ) : void {
313342 if ( ! this . appHostManager || ! this . mcpManager ) return ;
314343 const uiInfo = this . mcpManager . getUiToolMap ( ) . get ( toolName ) ;
315344 if ( ! uiInfo ) return ;
316345
346+ const payload = this . getToolPayload ( toolResult ) ;
317347 const existingAppId = this . registeredApps . get ( toolName ) ;
318348 if ( existingAppId ) {
319- if ( toolResult !== undefined ) {
349+ if ( payload !== undefined ) {
320350 this . appHostManager . pushToApp ( existingAppId , {
321351 jsonrpc : "2.0" ,
322352 method : "ui/notifications/tool-result" ,
323- params : toolResult ,
353+ params : payload ,
324354 } ) ;
325355 }
326356 return ;
@@ -339,11 +369,11 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
339369 } ) ;
340370 this . registeredApps . set ( toolName , app . id ) ;
341371 this . tui . addAppNotification ( app ) ;
342- if ( toolResult !== undefined ) {
372+ if ( payload !== undefined ) {
343373 this . appHostManager ! . pushToApp ( app . id , {
344374 jsonrpc : "2.0" ,
345375 method : "ui/notifications/tool-result" ,
346- params : toolResult ,
376+ params : payload ,
347377 } ) ;
348378 }
349379 } )
@@ -361,10 +391,11 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
361391 ) : void {
362392 if ( ! this . appHostManager ) return ;
363393
364- const result = toolResult as Record < string , unknown > | undefined ;
365- const structuredContent = result ?. structuredContent as Record < string , unknown > | undefined ;
394+ const payload = this . getToolPayload ( toolResult ) ;
395+ const structuredContent = this . getStructuredContent ( toolResult ) ;
396+ const result = payload as Record < string , unknown > | undefined ;
366397
367- if ( ! structuredContent || typeof structuredContent !== "object" ) {
398+ if ( ! structuredContent ) {
368399 this . tui . addInfo ( `[MDX] no structuredContent (keys: ${ result ? Object . keys ( result ) . join ( "," ) : "null" } )` ) ;
369400 return ;
370401 }
@@ -383,11 +414,11 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
383414 this . registeredApps . set ( toolName , app . id ) ;
384415 this . tui . addAppNotification ( app ) ;
385416
386- if ( toolResult !== undefined ) {
417+ if ( payload !== undefined ) {
387418 this . appHostManager ! . pushToApp ( app . id , {
388419 jsonrpc : "2.0" ,
389420 method : "ui/notifications/tool-result" ,
390- params : structuredContent ,
421+ params : payload ,
391422 } ) ;
392423 }
393424 } catch ( e : any ) {
@@ -414,14 +445,17 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
414445 case "tool_execution_start" :
415446 this . tui . toolStart ( event . toolName , event . args ) ;
416447 break ;
417- case "tool_execution_end" :
448+ case "tool_execution_end" : {
449+ const payload = this . getToolPayload ( event . result ) ;
450+ const effectiveIsError = this . getEffectiveToolError ( event . result , event . isError ) ;
418451 this . tui . toolEnd (
419452 event . toolName ,
420- event . result ,
421- event . isError ,
453+ payload ,
454+ effectiveIsError ,
422455 ) ;
423456 this . checkAndRegisterApp ( event . toolName , event . result ) ;
424457 break ;
458+ }
425459 }
426460 } ) ;
427461
@@ -449,4 +483,96 @@ You can also load tools by exact name using \`select:\`: for example \`search_to
449483 }
450484 } ) ;
451485 }
486+
487+ private handleMcpEvent ( event : MCPClientEvent ) : void {
488+ switch ( event . type ) {
489+ case "progress" : {
490+ const key = `${ event . serverName } :${ event . params . progressToken } ` ;
491+ const previous = this . lastMcpProgress . get ( key ) ;
492+ const next = {
493+ progress : event . params . progress ,
494+ total : event . params . total ,
495+ message : event . params . message ,
496+ } ;
497+ this . lastMcpProgress . set ( key , next ) ;
498+
499+ const shouldReport = ! previous
500+ || event . params . message !== previous . message
501+ || this . isProgressComplete ( event . params . progress , event . params . total )
502+ || this . progressBucket ( event . params . progress , event . params . total ) !== this . progressBucket ( previous . progress , previous . total ) ;
503+
504+ if ( shouldReport ) {
505+ const summary = this . formatProgress ( event . params . progress , event . params . total ) ;
506+ const detail = event . params . message ? ` ${ event . params . message } ` : "" ;
507+ this . tui . addInfo ( `MCP ${ event . serverName } : ${ summary } ${ detail } ` ) ;
508+ }
509+ return ;
510+ }
511+ case "message" : {
512+ const level = ( event . params . level ?? "info" ) . toLowerCase ( ) ;
513+ const prefix = `MCP ${ event . serverName } ` ;
514+ const text = this . stringifyMcpMessage ( event . params . data ) ;
515+ const label = event . params . logger ? `${ event . params . logger } : ` : "" ;
516+ if ( level === "error" ) {
517+ this . tui . addError ( `${ prefix } : ${ label } ${ text } ` ) ;
518+ } else if ( level === "warning" || level === "warn" ) {
519+ this . tui . addInfo ( `${ prefix } warning: ${ label } ${ text } ` ) ;
520+ } else {
521+ this . tui . addInfo ( `${ prefix } : ${ label } ${ text } ` ) ;
522+ }
523+ return ;
524+ }
525+ case "tools_list_changed" :
526+ this . tui . addInfo ( `MCP ${ event . serverName } : refreshing tool list...` ) ;
527+ return ;
528+ case "tools_refreshed" :
529+ this . tui . addInfo ( `MCP ${ event . serverName } : tool list refreshed (${ event . toolCount } tools)` ) ;
530+ return ;
531+ case "tools_refresh_failed" :
532+ this . tui . addError ( `MCP ${ event . serverName } : tool refresh failed: ${ event . error } ` ) ;
533+ return ;
534+ case "resources_list_changed" :
535+ this . tui . addInfo ( `MCP ${ event . serverName } : resources updated` ) ;
536+ return ;
537+ case "cancelled" :
538+ this . tui . addInfo ( `MCP ${ event . serverName } : request cancelled` + ( event . params . reason ? ` (${ event . params . reason } )` : "" ) ) ;
539+ return ;
540+ default :
541+ return ;
542+ }
543+ }
544+
545+ private progressBucket ( progress ?: number , total ?: number ) : number {
546+ if ( typeof progress !== "number" ) return - 1 ;
547+ if ( typeof total === "number" && total > 0 ) {
548+ return Math . min ( 10 , Math . floor ( ( progress / total ) * 10 ) ) ;
549+ }
550+ return Math . floor ( progress / 10 ) ;
551+ }
552+
553+ private isProgressComplete ( progress ?: number , total ?: number ) : boolean {
554+ if ( typeof progress !== "number" ) return false ;
555+ if ( typeof total === "number" && total > 0 ) {
556+ return progress >= total ;
557+ }
558+ return progress >= 100 ;
559+ }
560+
561+ private formatProgress ( progress ?: number , total ?: number ) : string {
562+ if ( typeof progress !== "number" ) return "progress update" ;
563+ if ( typeof total === "number" && total > 0 ) {
564+ const percent = Math . max ( 0 , Math . min ( 100 , Math . round ( ( progress / total ) * 100 ) ) ) ;
565+ return `progress ${ percent } % (${ progress } /${ total } )` ;
566+ }
567+ return `progress ${ progress } ` ;
568+ }
569+
570+ private stringifyMcpMessage ( data : unknown ) : string {
571+ if ( typeof data === "string" ) return data ;
572+ try {
573+ return JSON . stringify ( data ) ;
574+ } catch {
575+ return String ( data ) ;
576+ }
577+ }
452578}
0 commit comments