@@ -55,6 +55,64 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
5555 ? path . join ( import . meta. dirname , "dist" )
5656 : import . meta. dirname ;
5757
58+ // =============================================================================
59+ // Command Queue (shared across stateless server instances)
60+ // =============================================================================
61+
62+ /** Commands expire after this many ms if never polled */
63+ const COMMAND_TTL_MS = 60_000 ; // 60 seconds
64+
65+ /** Periodic sweep interval to drop stale queues */
66+ const SWEEP_INTERVAL_MS = 30_000 ; // 30 seconds
67+
68+ /** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
69+ const POLL_BATCH_WAIT_MS = 200 ;
70+
71+ export type PdfCommand =
72+ | { type : "navigate" ; page : number }
73+ | { type : "search" ; query : string }
74+ | { type : "find" ; query : string }
75+ | { type : "search_navigate" ; matchIndex : number }
76+ | { type : "zoom" ; scale : number } ;
77+
78+ interface QueueEntry {
79+ commands : PdfCommand [ ] ;
80+ /** Timestamp of the most recent enqueue or dequeue */
81+ lastActivity : number ;
82+ }
83+
84+ const commandQueues = new Map < string , QueueEntry > ( ) ;
85+
86+ function pruneStaleQueues ( ) : void {
87+ const now = Date . now ( ) ;
88+ for ( const [ uuid , entry ] of commandQueues ) {
89+ if ( now - entry . lastActivity > COMMAND_TTL_MS ) {
90+ commandQueues . delete ( uuid ) ;
91+ }
92+ }
93+ }
94+
95+ // Periodic sweep so abandoned queues don't leak
96+ setInterval ( pruneStaleQueues , SWEEP_INTERVAL_MS ) . unref ( ) ;
97+
98+ function enqueueCommand ( viewUUID : string , command : PdfCommand ) : void {
99+ let entry = commandQueues . get ( viewUUID ) ;
100+ if ( ! entry ) {
101+ entry = { commands : [ ] , lastActivity : Date . now ( ) } ;
102+ commandQueues . set ( viewUUID , entry ) ;
103+ }
104+ entry . commands . push ( command ) ;
105+ entry . lastActivity = Date . now ( ) ;
106+ }
107+
108+ function dequeueCommands ( viewUUID : string ) : PdfCommand [ ] {
109+ const entry = commandQueues . get ( viewUUID ) ;
110+ if ( ! entry ) return [ ] ;
111+ const commands = entry . commands ;
112+ commandQueues . delete ( viewUUID ) ;
113+ return commands ;
114+ }
115+
58116// =============================================================================
59117// URL Validation & Normalization
60118// =============================================================================
@@ -616,21 +674,166 @@ Accepts:
616674
617675 // Probe file size so the client can set up range transport without an extra fetch
618676 const { totalBytes } = await readPdfRange ( normalized , 0 , 1 ) ;
677+ const uuid = randomUUID ( ) ;
619678
620679 return {
621- content : [ { type : "text" , text : `Displaying PDF: ${ normalized } ` } ] ,
680+ content : [
681+ {
682+ type : "text" ,
683+ text : `Displaying PDF (viewUUID: ${ uuid } ): ${ normalized } . Use the interact tool with this viewUUID to navigate, search, zoom, etc.` ,
684+ } ,
685+ ] ,
622686 structuredContent : {
623687 url : normalized ,
624688 initialPage : page ,
625689 totalBytes,
626690 } ,
627691 _meta : {
628- viewUUID : randomUUID ( ) ,
692+ viewUUID : uuid ,
629693 } ,
630694 } ;
631695 } ,
632696 ) ;
633697
698+ // Tool: interact - Interact with an existing PDF viewer
699+ server . registerTool (
700+ "interact" ,
701+ {
702+ title : "Interact with PDF" ,
703+ description : `Send an action to an existing PDF viewer. Actions are queued and batched.
704+
705+ Actions:
706+ - navigate: Go to a page. Requires \`page\`.
707+ - search: Search text and highlight matches in UI. Requires \`query\`. Results (with excerpts, pages, offsets) appear in model context.
708+ - find: Search text silently (no UI change). Requires \`query\`. Results appear in model context only.
709+ - search_navigate: Jump to a search match. Requires \`matchIndex\` (from search/find results).
710+ - zoom: Set zoom level. Requires \`scale\` (0.5–3.0).` ,
711+ inputSchema : {
712+ viewUUID : z
713+ . string ( )
714+ . describe ( "The viewUUID of the PDF viewer (from display_pdf result)" ) ,
715+ action : z
716+ . enum ( [ "navigate" , "search" , "find" , "search_navigate" , "zoom" ] )
717+ . describe ( "Action to perform" ) ,
718+ page : z
719+ . number ( )
720+ . min ( 1 )
721+ . optional ( )
722+ . describe ( "Page number (for navigate)" ) ,
723+ query : z
724+ . string ( )
725+ . optional ( )
726+ . describe ( "Search text (for search / find)" ) ,
727+ matchIndex : z
728+ . number ( )
729+ . min ( 0 )
730+ . optional ( )
731+ . describe ( "Match index (for search_navigate)" ) ,
732+ scale : z
733+ . number ( )
734+ . min ( 0.5 )
735+ . max ( 3.0 )
736+ . optional ( )
737+ . describe ( "Zoom scale, 1.0 = 100% (for zoom)" ) ,
738+ } ,
739+ } ,
740+ async ( {
741+ viewUUID : uuid ,
742+ action,
743+ page,
744+ query,
745+ matchIndex,
746+ scale,
747+ } ) : Promise < CallToolResult > => {
748+ let description : string ;
749+ switch ( action ) {
750+ case "navigate" :
751+ if ( page == null )
752+ return {
753+ content : [ { type : "text" , text : "navigate requires `page`" } ] ,
754+ isError : true ,
755+ } ;
756+ enqueueCommand ( uuid , { type : "navigate" , page } ) ;
757+ description = `navigate to page ${ page } ` ;
758+ break ;
759+ case "search" :
760+ if ( ! query )
761+ return {
762+ content : [ { type : "text" , text : "search requires `query`" } ] ,
763+ isError : true ,
764+ } ;
765+ enqueueCommand ( uuid , { type : "search" , query } ) ;
766+ description = `search for "${ query } "` ;
767+ break ;
768+ case "find" :
769+ if ( ! query )
770+ return {
771+ content : [ { type : "text" , text : "find requires `query`" } ] ,
772+ isError : true ,
773+ } ;
774+ enqueueCommand ( uuid , { type : "find" , query } ) ;
775+ description = `find "${ query } " (silent)` ;
776+ break ;
777+ case "search_navigate" :
778+ if ( matchIndex == null )
779+ return {
780+ content : [
781+ {
782+ type : "text" ,
783+ text : "search_navigate requires `matchIndex`" ,
784+ } ,
785+ ] ,
786+ isError : true ,
787+ } ;
788+ enqueueCommand ( uuid , { type : "search_navigate" , matchIndex } ) ;
789+ description = `go to match #${ matchIndex } ` ;
790+ break ;
791+ case "zoom" :
792+ if ( scale == null )
793+ return {
794+ content : [ { type : "text" , text : "zoom requires `scale`" } ] ,
795+ isError : true ,
796+ } ;
797+ enqueueCommand ( uuid , { type : "zoom" , scale } ) ;
798+ description = `zoom to ${ Math . round ( scale * 100 ) } %` ;
799+ break ;
800+ default :
801+ return {
802+ content : [ { type : "text" , text : `Unknown action: ${ action } ` } ] ,
803+ isError : true ,
804+ } ;
805+ }
806+ return {
807+ content : [ { type : "text" , text : `Queued: ${ description } ` } ] ,
808+ } ;
809+ } ,
810+ ) ;
811+
812+ // Tool: poll_pdf_commands (app-only) - Poll for pending commands
813+ registerAppTool (
814+ server ,
815+ "poll_pdf_commands" ,
816+ {
817+ title : "Poll PDF Commands" ,
818+ description : "Poll for pending commands for a PDF viewer" ,
819+ inputSchema : {
820+ viewUUID : z . string ( ) . describe ( "The viewUUID of the PDF viewer" ) ,
821+ } ,
822+ _meta : { ui : { visibility : [ "app" ] } } ,
823+ } ,
824+ async ( { viewUUID : uuid } ) : Promise < CallToolResult > => {
825+ // If commands are queued, wait a fixed window to let more accumulate
826+ if ( commandQueues . has ( uuid ) ) {
827+ await new Promise ( ( r ) => setTimeout ( r , POLL_BATCH_WAIT_MS ) ) ;
828+ }
829+ const commands = dequeueCommands ( uuid ) ;
830+ return {
831+ content : [ { type : "text" , text : `${ commands . length } command(s)` } ] ,
832+ structuredContent : { commands } ,
833+ } ;
834+ } ,
835+ ) ;
836+
634837 // Resource: UI HTML
635838 registerAppResource (
636839 server ,
0 commit comments