@@ -7,13 +7,121 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77import { createServer } from "node:http" ;
88import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" ;
99import { AddressInfo } from "node:net" ;
10- import { z } from "zod" ;
1110import { logger } from "@/ui/logger" ;
1211import { ApiSessionClient } from "@/api/apiSession" ;
1312import { randomUUID } from "node:crypto" ;
1413import { isLowSignalTitle , normalizeTitleCandidate } from "@/utils/titlePolicy" ;
14+ import { configuration } from "@/configuration" ;
15+ import { getAuthToken } from "@/api/auth" ;
16+ import {
17+ changeTitleInputSchema ,
18+ reportAddAssetInputSchema ,
19+ reportCreateInputSchema ,
20+ reportCreateShareInputSchema ,
21+ reportGetInputSchema ,
22+ reportListInputSchema ,
23+ reportUpdateInputSchema
24+ } from "@/mcp/hapiMcpTools" ;
25+
26+ type JsonObject = Record < string , unknown > ;
27+
28+ function summarizeJson ( payload : unknown ) : string {
29+ return JSON . stringify ( payload , null , 2 ) ;
30+ }
1531
1632export async function startHappyServer ( client : ApiSessionClient ) {
33+ let cachedWebToken : { token : string ; expiresAt : number } | null = null ;
34+
35+ const resolveHubUrl = ( path : string ) : string => {
36+ const normalizedBase = configuration . apiUrl . endsWith ( "/" )
37+ ? configuration . apiUrl
38+ : `${ configuration . apiUrl } /` ;
39+ return new URL ( path , normalizedBase ) . toString ( ) ;
40+ } ;
41+
42+ const getWebToken = async ( ) : Promise < string > => {
43+ if ( cachedWebToken && cachedWebToken . expiresAt > Date . now ( ) ) {
44+ return cachedWebToken . token ;
45+ }
46+
47+ const response = await fetch ( resolveHubUrl ( "/api/auth" ) , {
48+ method : "POST" ,
49+ headers : {
50+ "Content-Type" : "application/json"
51+ } ,
52+ body : JSON . stringify ( { accessToken : getAuthToken ( ) } )
53+ } ) ;
54+
55+ const payload = await response . json ( ) . catch ( ( ) => null ) as JsonObject | null ;
56+ if ( ! response . ok ) {
57+ const message = typeof payload ?. error === "string"
58+ ? payload . error
59+ : `Auth failed (${ response . status } )` ;
60+ throw new Error ( message ) ;
61+ }
62+
63+ const token = typeof payload ?. token === "string" ? payload . token : "" ;
64+ if ( ! token ) {
65+ throw new Error ( "Auth succeeded but token is missing" ) ;
66+ }
67+
68+ cachedWebToken = {
69+ token,
70+ expiresAt : Date . now ( ) + 13 * 60 * 1000
71+ } ;
72+ return token ;
73+ } ;
74+
75+ const requestHubJson = async ( path : string , init ?: RequestInit ) : Promise < JsonObject > => {
76+ const doRequest = async ( token : string ) : Promise < Response > => {
77+ const headers = new Headers ( init ?. headers ) ;
78+ headers . set ( "Authorization" , `Bearer ${ token } ` ) ;
79+ if ( init ?. body !== undefined && ! headers . has ( "Content-Type" ) ) {
80+ headers . set ( "Content-Type" , "application/json" ) ;
81+ }
82+ return await fetch ( resolveHubUrl ( path ) , {
83+ ...init ,
84+ headers
85+ } ) ;
86+ } ;
87+
88+ let response = await doRequest ( await getWebToken ( ) ) ;
89+ if ( response . status === 401 ) {
90+ cachedWebToken = null ;
91+ response = await doRequest ( await getWebToken ( ) ) ;
92+ }
93+
94+ const payload = await response . json ( ) . catch ( ( ) => null ) as JsonObject | null ;
95+ if ( ! response . ok ) {
96+ const message = typeof payload ?. error === "string"
97+ ? payload . error
98+ : `Request failed (${ response . status } )` ;
99+ throw new Error ( message ) ;
100+ }
101+
102+ return payload ?? { } ;
103+ } ;
104+
105+ const buildToolResult = ( payload : JsonObject , summary : string ) => ( {
106+ content : [
107+ {
108+ type : "text" as const ,
109+ text : `${ summary } \n\n${ summarizeJson ( payload ) } `
110+ }
111+ ] ,
112+ isError : false
113+ } ) ;
114+
115+ const buildToolError = ( label : string , error : unknown ) => ( {
116+ content : [
117+ {
118+ type : "text" as const ,
119+ text : `${ label } : ${ error instanceof Error ? error . message : String ( error ) } `
120+ }
121+ ] ,
122+ isError : true
123+ } ) ;
124+
17125 // Handler that sends title updates via the client
18126 const handler = async ( title : string ) => {
19127 const normalizedTitle = normalizeTitleCandidate ( title ) ;
@@ -60,11 +168,6 @@ export async function startHappyServer(client: ApiSessionClient) {
60168 version : "1.0.0" ,
61169 } ) ;
62170
63- // Avoid TS instantiation depth issues by widening the schema type.
64- const changeTitleInputSchema : z . ZodTypeAny = z . object ( {
65- title : z . string ( ) . describe ( 'The new title for the chat session' ) ,
66- } ) ;
67-
68171 mcp . registerTool < any , any > ( 'change_title' , {
69172 description : 'Change the title of the current chat session' ,
70173 title : 'Change Chat Title' ,
@@ -99,6 +202,163 @@ export async function startHappyServer(client: ApiSessionClient) {
99202 }
100203 } ) ;
101204
205+ mcp . registerTool < any , any > ( 'report_create' , {
206+ description : 'Create a markdown report and optionally create a public share link' ,
207+ title : 'Create Report' ,
208+ inputSchema : reportCreateInputSchema
209+ } , async ( args : {
210+ session_id ?: string
211+ task_id ?: string
212+ title ?: string
213+ status ?: string
214+ markdown ?: string
215+ metadata ?: unknown
216+ create_share ?: boolean
217+ share_expires_in_hours ?: number
218+ } ) => {
219+ try {
220+ const payload = await requestHubJson ( '/api/reports' , {
221+ method : 'POST' ,
222+ body : JSON . stringify ( {
223+ sessionId : args . session_id ?? client . sessionId ,
224+ taskId : args . task_id ,
225+ title : args . title ,
226+ status : args . status ,
227+ markdown : args . markdown ,
228+ metadata : args . metadata ,
229+ createShare : args . create_share ,
230+ shareExpiresInHours : args . share_expires_in_hours
231+ } )
232+ } ) ;
233+ const report = payload . report as JsonObject | undefined ;
234+ const shareUrl = typeof report ?. publicShareUrl === 'string' ? report . publicShareUrl : null ;
235+ const summary = shareUrl
236+ ? `Report created. Public share: ${ shareUrl } `
237+ : 'Report created.' ;
238+ return buildToolResult ( payload , summary ) ;
239+ } catch ( error ) {
240+ return buildToolError ( 'Failed to create report' , error ) ;
241+ }
242+ } ) ;
243+
244+ mcp . registerTool < any , any > ( 'report_update' , {
245+ description : 'Update report title/status/markdown/metadata' ,
246+ title : 'Update Report' ,
247+ inputSchema : reportUpdateInputSchema
248+ } , async ( args : {
249+ report_id : string
250+ task_id ?: string
251+ title ?: string
252+ status ?: string
253+ markdown ?: string
254+ metadata ?: unknown
255+ } ) => {
256+ try {
257+ const payload = await requestHubJson ( `/api/reports/${ encodeURIComponent ( args . report_id ) } ` , {
258+ method : 'PATCH' ,
259+ body : JSON . stringify ( {
260+ taskId : args . task_id ,
261+ title : args . title ,
262+ status : args . status ,
263+ markdown : args . markdown ,
264+ metadata : args . metadata
265+ } )
266+ } ) ;
267+ return buildToolResult ( payload , `Report updated: ${ args . report_id } ` ) ;
268+ } catch ( error ) {
269+ return buildToolError ( `Failed to update report ${ args . report_id } ` , error ) ;
270+ }
271+ } ) ;
272+
273+ mcp . registerTool < any , any > ( 'report_get' , {
274+ description : 'Get full report details by report_id' ,
275+ title : 'Get Report' ,
276+ inputSchema : reportGetInputSchema
277+ } , async ( args : { report_id : string } ) => {
278+ try {
279+ const payload = await requestHubJson ( `/api/reports/${ encodeURIComponent ( args . report_id ) } ` ) ;
280+ return buildToolResult ( payload , `Report loaded: ${ args . report_id } ` ) ;
281+ } catch ( error ) {
282+ return buildToolError ( `Failed to load report ${ args . report_id } ` , error ) ;
283+ }
284+ } ) ;
285+
286+ mcp . registerTool < any , any > ( 'report_list' , {
287+ description : 'List reports in current namespace' ,
288+ title : 'List Reports' ,
289+ inputSchema : reportListInputSchema
290+ } , async ( args : { limit ?: number ; session_id ?: string } ) => {
291+ try {
292+ const query = new URLSearchParams ( ) ;
293+ if ( typeof args . limit === 'number' ) {
294+ query . set ( 'limit' , `${ args . limit } ` ) ;
295+ }
296+ if ( typeof args . session_id === 'string' && args . session_id . trim ( ) . length > 0 ) {
297+ query . set ( 'sessionId' , args . session_id . trim ( ) ) ;
298+ }
299+ const suffix = query . size > 0 ? `?${ query . toString ( ) } ` : '' ;
300+ const payload = await requestHubJson ( `/api/reports${ suffix } ` ) ;
301+ return buildToolResult ( payload , 'Reports loaded.' ) ;
302+ } catch ( error ) {
303+ return buildToolError ( 'Failed to list reports' , error ) ;
304+ }
305+ } ) ;
306+
307+ mcp . registerTool < any , any > ( 'report_add_asset' , {
308+ description : 'Attach image/file asset to a report using base64/data-url/source-path' ,
309+ title : 'Add Report Asset' ,
310+ inputSchema : reportAddAssetInputSchema
311+ } , async ( args : {
312+ report_id : string
313+ filename ?: string
314+ mime_type ?: string
315+ content_base64 ?: string
316+ content_data_url ?: string
317+ source_path ?: string
318+ caption ?: string
319+ } ) => {
320+ try {
321+ const content = args . content_data_url ?? args . content_base64 ;
322+ const payload = await requestHubJson ( `/api/reports/${ encodeURIComponent ( args . report_id ) } /assets` , {
323+ method : 'POST' ,
324+ body : JSON . stringify ( {
325+ filename : args . filename ,
326+ mimeType : args . mime_type ,
327+ content,
328+ sourcePath : args . source_path ,
329+ caption : args . caption
330+ } )
331+ } ) ;
332+ return buildToolResult ( payload , `Asset added to report: ${ args . report_id } ` ) ;
333+ } catch ( error ) {
334+ return buildToolError ( `Failed to add asset for report ${ args . report_id } ` , error ) ;
335+ }
336+ } ) ;
337+
338+ mcp . registerTool < any , any > ( 'report_create_share' , {
339+ description : 'Create a public share link for a report' ,
340+ title : 'Create Report Share' ,
341+ inputSchema : reportCreateShareInputSchema
342+ } , async ( args : { report_id : string ; expires_in_hours ?: number ; created_by ?: string } ) => {
343+ try {
344+ const payload = await requestHubJson ( `/api/reports/${ encodeURIComponent ( args . report_id ) } /shares` , {
345+ method : 'POST' ,
346+ body : JSON . stringify ( {
347+ expiresInHours : args . expires_in_hours ,
348+ createdBy : args . created_by
349+ } )
350+ } ) ;
351+ const share = payload . share as JsonObject | undefined ;
352+ const shareUrl = typeof share ?. shareUrl === 'string' ? share . shareUrl : null ;
353+ const summary = shareUrl
354+ ? `Report share created: ${ shareUrl } `
355+ : `Report share created for ${ args . report_id } ` ;
356+ return buildToolResult ( payload , summary ) ;
357+ } catch ( error ) {
358+ return buildToolError ( `Failed to create share for report ${ args . report_id } ` , error ) ;
359+ }
360+ } ) ;
361+
102362 const transport = new StreamableHTTPServerTransport ( {
103363 // NOTE: Returning session id here will result in claude
104364 // sdk spawn to fail with `Invalid Request: Server already initialized`
@@ -130,7 +390,15 @@ export async function startHappyServer(client: ApiSessionClient) {
130390
131391 return {
132392 url : baseUrl . toString ( ) ,
133- toolNames : [ 'change_title' ] ,
393+ toolNames : [
394+ 'change_title' ,
395+ 'report_create' ,
396+ 'report_update' ,
397+ 'report_get' ,
398+ 'report_list' ,
399+ 'report_add_asset' ,
400+ 'report_create_share'
401+ ] ,
134402 stop : ( ) => {
135403 logger . debug ( '[hapiMCP] Stopping server' ) ;
136404 mcp . close ( ) ;
0 commit comments