Skip to content

Commit b0c9574

Browse files
committed
feat(report): add report storage, APIs, and MCP tools
1 parent 0204679 commit b0c9574

22 files changed

Lines changed: 2458 additions & 37 deletions

File tree

cli/src/claude/utils/startHappyServer.ts

Lines changed: 275 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,121 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { createServer } from "node:http";
88
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
99
import { AddressInfo } from "node:net";
10-
import { z } from "zod";
1110
import { logger } from "@/ui/logger";
1211
import { ApiSessionClient } from "@/api/apiSession";
1312
import { randomUUID } from "node:crypto";
1413
import { 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

1632
export 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();

cli/src/codex/happyMcpStdioBridge.ts

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1515
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
1616
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1717
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
18-
import { z } from 'zod';
18+
import { HAPI_MCP_TOOL_DEFINITIONS } from '@/mcp/hapiMcpTools';
1919

2020
function parseArgs(argv: string[]): { url: string | null } {
2121
let url: string | null = null;
@@ -64,34 +64,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
6464
version: '1.0.0',
6565
});
6666

67-
// Register the single tool and forward to HTTP MCP
68-
const changeTitleInputSchema: z.ZodTypeAny = z.object({
69-
title: z.string().describe('The new title for the chat session'),
70-
});
71-
72-
server.registerTool<any, any>(
73-
'change_title',
74-
{
75-
description: 'Change the title of the current chat session',
76-
title: 'Change Chat Title',
77-
inputSchema: changeTitleInputSchema,
78-
},
79-
async (args: Record<string, unknown>) => {
80-
try {
81-
const client = await ensureHttpClient();
82-
const response = await client.callTool({ name: 'change_title', arguments: args });
83-
// Pass-through response from HTTP server
84-
return response as any;
85-
} catch (error) {
86-
return {
87-
content: [
88-
{ type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
89-
],
90-
isError: true,
91-
};
67+
for (const definition of HAPI_MCP_TOOL_DEFINITIONS) {
68+
server.registerTool<any, any>(
69+
definition.name,
70+
{
71+
description: definition.description,
72+
title: definition.title,
73+
inputSchema: definition.inputSchema,
74+
},
75+
async (args: Record<string, unknown>) => {
76+
try {
77+
const client = await ensureHttpClient();
78+
const response = await client.callTool({ name: definition.name, arguments: args });
79+
// Pass-through response from HTTP server
80+
return response as any;
81+
} catch (error) {
82+
return {
83+
content: [
84+
{
85+
type: 'text' as const,
86+
text: `Failed to call ${definition.name}: ${error instanceof Error ? error.message : String(error)}`
87+
},
88+
],
89+
isError: true,
90+
};
91+
}
9292
}
93-
}
94-
);
93+
);
94+
}
9595

9696
// Start STDIO transport
9797
const stdio = new StdioServerTransport();

0 commit comments

Comments
 (0)