@@ -20,6 +20,7 @@ import { renderModalStates } from './tab';
2020import type { Tab , TabSnapshot } from './tab' ;
2121import type { CallToolResult , ImageContent , TextContent } from '@modelcontextprotocol/sdk/types.js' ;
2222import type { Context } from './context' ;
23+ import type { ModalState } from './tools/tool' ;
2324
2425export const requestDebug = debug ( 'pw:mcp:request' ) ;
2526
@@ -30,6 +31,7 @@ export class Response {
3031 private _context : Context ;
3132 private _includeSnapshot : 'none' | 'full' | 'incremental' = 'none' ;
3233 private _includeTabs = false ;
34+ private _includeModalStates : ModalState [ ] | undefined ;
3335 private _tabSnapshot : TabSnapshot | undefined ;
3436
3537 readonly toolName : string ;
@@ -87,6 +89,10 @@ export class Response {
8789 this . _includeTabs = true ;
8890 }
8991
92+ setIncludeModalStates ( modalStates : ModalState [ ] ) {
93+ this . _includeModalStates = modalStates ;
94+ }
95+
9096 async finish ( ) {
9197 // All the async snapshotting post-action is happening here.
9298 // Everything below should race against modal states.
@@ -110,42 +116,44 @@ export class Response {
110116 requestDebug ( this . serialize ( { omitSnapshot : true , omitBlobs : true } ) ) ;
111117 }
112118
113- serialize ( options : { omitSnapshot ?: boolean , omitBlobs ?: boolean } = { } ) : { content : ( TextContent | ImageContent ) [ ] , isError ?: boolean } {
114- const response : string [ ] = [ ] ;
119+ serialize ( options : { omitSnapshot ?: boolean , omitBlobs ?: boolean , _meta ?: Record < string , any > } = { } ) : { content : ( TextContent | ImageContent ) [ ] , isError ?: boolean , _meta ?: Record < string , any > } {
120+ const renderedResponse = new RenderedResponse ( ) ;
115121
116- // Start with command result.
117- if ( this . _result . length ) {
118- response . push ( '### Result' ) ;
119- response . push ( this . _result . join ( '\n' ) ) ;
120- response . push ( '' ) ;
121- }
122+ if ( this . _result . length )
123+ renderedResponse . results . push ( ...this . _result ) ;
122124
123125 // Add code if it exists.
124- if ( this . _code . length ) {
125- response . push ( `### Ran Playwright code
126- \`\`\`js
127- ${ this . _code . join ( '\n' ) }
128- \`\`\`` ) ;
129- response . push ( '' ) ;
130- }
126+ if ( this . _code . length )
127+ renderedResponse . code . push ( ...this . _code ) ;
131128
132129 // List browser tabs.
133- if ( this . _includeSnapshot !== 'none' || this . _includeTabs )
134- response . push ( ...renderTabsMarkdown ( this . _context . tabs ( ) , this . _includeTabs ) ) ;
130+ if ( this . _includeSnapshot !== 'none' || this . _includeTabs ) {
131+ const tabsMarkdown = renderTabsMarkdown ( this . _context . tabs ( ) , this . _includeTabs ) ;
132+ if ( tabsMarkdown . length )
133+ renderedResponse . states . tabs = tabsMarkdown . join ( '\n' ) ;
134+ }
135135
136136 // Add snapshot if provided.
137137 if ( this . _tabSnapshot ?. modalStates . length ) {
138- response . push ( ... renderModalStates ( this . _context , this . _tabSnapshot . modalStates ) ) ;
139- response . push ( ' ') ;
138+ const modalStatesMarkdown = renderModalStates ( this . _tabSnapshot . modalStates ) ;
139+ renderedResponse . states . modal = modalStatesMarkdown . join ( '\n ') ;
140140 } else if ( this . _tabSnapshot ) {
141141 const includeSnapshot = options . omitSnapshot ? 'none' : this . _includeSnapshot ;
142- response . push ( renderTabSnapshot ( this . _tabSnapshot , includeSnapshot ) ) ;
143- response . push ( '' ) ;
142+ renderTabSnapshot ( this . _tabSnapshot , includeSnapshot , renderedResponse ) ;
143+ } else if ( this . _includeModalStates ) {
144+ const modalStatesMarkdown = renderModalStates ( this . _includeModalStates ) ;
145+ renderedResponse . states . modal = modalStatesMarkdown . join ( '\n' ) ;
144146 }
145147
148+ const redactedResponse = this . _context . config . secrets ? renderedResponse . redact ( this . _context . config . secrets ) : renderedResponse ;
149+
150+ // Structured response.
151+ const includeMeta = options . _meta && 'dev.lowire/history' in options . _meta && 'dev.lowire/state' in options . _meta ;
152+ const _meta : any = includeMeta ? redactedResponse . asMeta ( ) : undefined ;
153+
146154 // Main response part
147155 const content : ( TextContent | ImageContent ) [ ] = [
148- { type : 'text' , text : response . join ( '\n' ) } ,
156+ { type : 'text' , text : redactedResponse . asText ( ) } ,
149157 ] ;
150158
151159 // Image attachments.
@@ -154,50 +162,39 @@ ${this._code.join('\n')}
154162 content . push ( { type : 'image' , data : options . omitBlobs ? '<blob>' : image . data . toString ( 'base64' ) , mimeType : image . contentType } ) ;
155163 }
156164
157- this . _redactSecrets ( content ) ;
158- return { content, isError : this . _isError } ;
159- }
160-
161- private _redactSecrets ( content : ( TextContent | ImageContent ) [ ] ) {
162- if ( ! this . _context . config . secrets )
163- return ;
164-
165- for ( const item of content ) {
166- if ( item . type !== 'text' )
167- continue ;
168- for ( const [ secretName , secretValue ] of Object . entries ( this . _context . config . secrets ) )
169- item . text = item . text . replaceAll ( secretValue , `<secret>${ secretName } </secret>` ) ;
170- }
165+ return {
166+ _meta,
167+ content,
168+ isError : this . _isError
169+ } ;
171170 }
172171}
173172
174- function renderTabSnapshot ( tabSnapshot : TabSnapshot , includeSnapshot : 'none' | 'full' | 'incremental' ) : string {
175- const lines : string [ ] = [ ] ;
176-
173+ function renderTabSnapshot ( tabSnapshot : TabSnapshot , includeSnapshot : 'none' | 'full' | 'incremental' , response : RenderedResponse ) {
177174 if ( tabSnapshot . consoleMessages . length ) {
178- lines . push ( `### New console messages` ) ;
175+ const lines : string [ ] = [ ] ;
179176 for ( const message of tabSnapshot . consoleMessages )
180177 lines . push ( `- ${ trim ( message . toString ( ) , 100 ) } ` ) ;
181- lines . push ( '' ) ;
178+ response . updates . push ( { category : 'console' , content : lines . join ( '\n' ) } ) ;
182179 }
183180
184181 if ( tabSnapshot . downloads . length ) {
185- lines . push ( `### Downloads` ) ;
182+ const lines : string [ ] = [ ] ;
186183 for ( const entry of tabSnapshot . downloads ) {
187184 if ( entry . finished )
188185 lines . push ( `- Downloaded file ${ entry . download . suggestedFilename ( ) } to ${ entry . outputFile } ` ) ;
189186 else
190187 lines . push ( `- Downloading file ${ entry . download . suggestedFilename ( ) } ...` ) ;
191188 }
192- lines . push ( '' ) ;
189+ response . updates . push ( { category : 'downloads' , content : lines . join ( '\n' ) } ) ;
193190 }
194191
195192 if ( includeSnapshot === 'incremental' && tabSnapshot . ariaSnapshotDiff === '' ) {
196193 // When incremental snapshot is present, but empty, do not render page state altogether.
197- return lines . join ( '\n' ) ;
194+ return ;
198195 }
199196
200- lines . push ( `### Page state` ) ;
197+ const lines : string [ ] = [ ] ;
201198 lines . push ( `- Page URL: ${ tabSnapshot . url } ` ) ;
202199 lines . push ( `- Page Title: ${ tabSnapshot . title } ` ) ;
203200
@@ -210,29 +207,22 @@ function renderTabSnapshot(tabSnapshot: TabSnapshot, includeSnapshot: 'none' | '
210207 lines . push ( tabSnapshot . ariaSnapshot ) ;
211208 lines . push ( '```' ) ;
212209 }
213-
214- return lines . join ( '\n' ) ;
210+ response . states . page = lines . join ( '\n' ) ;
215211}
216212
217213function renderTabsMarkdown ( tabs : Tab [ ] , force : boolean = false ) : string [ ] {
218214 if ( tabs . length === 1 && ! force )
219215 return [ ] ;
220216
221- if ( ! tabs . length ) {
222- return [
223- '### Open tabs' ,
224- 'No open tabs. Use the "browser_navigate" tool to navigate to a page first.' ,
225- '' ,
226- ] ;
227- }
217+ if ( ! tabs . length )
218+ return [ 'No open tabs. Use the "browser_navigate" tool to navigate to a page first.' ] ;
228219
229- const lines : string [ ] = [ '### Open tabs' ] ;
220+ const lines : string [ ] = [ ] ;
230221 for ( let i = 0 ; i < tabs . length ; i ++ ) {
231222 const tab = tabs [ i ] ;
232223 const current = tab . isCurrentTab ( ) ? ' (current)' : '' ;
233224 lines . push ( `- ${ i } :${ current } [${ tab . lastTitle ( ) } ] (${ tab . page . url ( ) } )` ) ;
234225 }
235- lines . push ( '' ) ;
236226 return lines ;
237227}
238228
@@ -242,6 +232,86 @@ function trim(text: string, maxLength: number) {
242232 return text . slice ( 0 , maxLength ) + '...' ;
243233}
244234
235+ export class RenderedResponse {
236+ readonly states : Partial < Record < 'page' | 'tabs' | 'modal' , string > > = { } ;
237+ readonly updates : { category : 'console' | 'downloads' , content : string } [ ] = [ ] ;
238+ readonly results : string [ ] = [ ] ;
239+ readonly code : string [ ] = [ ] ;
240+
241+ constructor ( copy ?: { states : Partial < Record < 'page' | 'tabs' | 'modal' , string > > , updates : { category : 'console' | 'downloads' , content : string } [ ] , results : string [ ] , code : string [ ] } ) {
242+ if ( copy ) {
243+ this . states = copy . states ;
244+ this . updates = copy . updates ;
245+ this . results = copy . results ;
246+ this . code = copy . code ;
247+ }
248+ }
249+
250+ asText ( ) : string {
251+ const text : string [ ] = [ ] ;
252+ if ( this . results . length )
253+ text . push ( `### Result\n${ this . results . join ( '\n' ) } \n` ) ;
254+ if ( this . code . length )
255+ text . push ( `### Ran Playwright code\n${ this . code . join ( '\n' ) } \n` ) ;
256+
257+ for ( const { category, content } of this . updates ) {
258+ if ( ! content . trim ( ) )
259+ continue ;
260+
261+ switch ( category ) {
262+ case 'console' :
263+ text . push ( `### New console messages\n${ content } \n` ) ;
264+ break ;
265+ case 'downloads' :
266+ text . push ( `### Downloads\n${ content } \n` ) ;
267+ break ;
268+ }
269+ }
270+
271+ for ( const [ category , value ] of Object . entries ( this . states ) ) {
272+ if ( ! value . trim ( ) )
273+ continue ;
274+
275+ switch ( category ) {
276+ case 'page' :
277+ text . push ( `### Page state\n${ value } \n` ) ;
278+ break ;
279+ case 'tabs' :
280+ text . push ( `### Open tabs\n${ value } \n` ) ;
281+ break ;
282+ case 'modal' :
283+ text . push ( `### Modal state\n${ value } \n` ) ;
284+ break ;
285+ }
286+ }
287+ return text . join ( '\n' ) ;
288+ }
289+
290+ asMeta ( ) {
291+ const codeUpdate = this . code . length ? { category : 'code' , content : this . code . join ( '\n' ) } : undefined ;
292+ const resultUpdate = this . results . length ? { category : 'result' , content : this . results . join ( '\n' ) } : undefined ;
293+ const updates = [ resultUpdate , codeUpdate , ...this . updates ] . filter ( Boolean ) ;
294+ return {
295+ 'dev.lowire/history' : updates ,
296+ 'dev.lowire/state' : { ...this . states } ,
297+ } ;
298+ }
299+
300+ redact ( secrets : Record < string , string > ) : RenderedResponse {
301+ const redactText = ( text : string ) => {
302+ for ( const [ secretName , secretValue ] of Object . entries ( secrets ) )
303+ text = text . replaceAll ( secretValue , `<secret>${ secretName } </secret>` ) ;
304+ return text ;
305+ } ;
306+
307+ const updates = this . updates . map ( update => ( { ...update , content : redactText ( update . content ) } ) ) ;
308+ const results = this . results . map ( result => redactText ( result ) ) ;
309+ const code = this . code . map ( code => redactText ( code ) ) ;
310+ const states = Object . fromEntries ( Object . entries ( this . states ) . map ( ( [ key , value ] ) => [ key , redactText ( value ) ] ) ) ;
311+ return new RenderedResponse ( { states, updates, results, code } ) ;
312+ }
313+ }
314+
245315function parseSections ( text : string ) : Map < string , string > {
246316 const sections = new Map < string , string > ( ) ;
247317 const sectionHeaders = text . split ( / ^ # # # / m) . slice ( 1 ) ; // Remove empty first element
@@ -286,5 +356,6 @@ export function parseResponse(response: CallToolResult) {
286356 downloads,
287357 isError,
288358 attachments,
359+ _meta : response . _meta ,
289360 } ;
290361}
0 commit comments