From 4bc33939044176b8f7fa981492e846d7c927d9d2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:11:18 +0100 Subject: [PATCH] fix(cli): stateful MCP HTTP transport for display_image (#944) * fix(cli): stateful MCP HTTP transport for display_image MCP SDK 1.29+ rejects stateless StreamableHTTP reuse across separate POSTs (initialize, notifications/initialized, tools/call), so display_image 500'd on the second request. Generate per-session IDs instead. Add hapi-display-image.mjs to call the live session CLI's MCP via hostPid so generated-image bytes stay in the owning process. Co-authored-by: Cursor * fix(cli): multi-session MCP transport + hapiMcpUrl metadata Route streamable HTTP by mcp-session-id so agent bridge and hapi-display-image can each initialize without "already initialized". Publish metadata.hapiMcpUrl at MCP start; helper uses that instead of guessing loopback ports (hook server collision). Co-authored-by: Cursor * fix(scripts): preserve namespaced CLI_API_TOKEN in display-image helper Do not append :default; namespace is already encoded in the stored token. Co-authored-by: Cursor * fix(scripts): read settings only when CLI_API_TOKEN unset Env-only auth must not require ~/.hapi/settings.json to exist. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/claude/utils/startHappyServer.ts | 111 +++++++++++++++-------- scripts/tooling/hapi-display-image.mjs | 76 ++++++++++++++++ shared/src/schemas.ts | 1 + 3 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 scripts/tooling/hapi-display-image.mjs diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index df0b0fd4..b2001a57 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -4,7 +4,7 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage } from "node:http"; import { lstat, readFile } from "node:fs/promises"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { AddressInfo } from "node:net"; @@ -18,38 +18,29 @@ type StartHappyServerOptions = { emitTitleSummary?: boolean; }; -export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { - const emitTitleSummary = options.emitTitleSummary ?? true; - - // Handler that sends title updates via the client +function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { if (emitTitleSummary) { - // Send title as a summary message, similar to title generator. client.sendClaudeSessionMessage({ type: 'summary', summary: title, leafUuid: randomUUID() }); } - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }; - // - // Create the MCP server - // - const mcp = new McpServer({ name: "HAPI MCP", version: "1.0.0", }); - // Avoid TS instantiation depth issues by widening the schema type. const changeTitleInputSchema: z.ZodTypeAny = z.object({ title: z.string().describe('The new title for the chat session'), }); @@ -66,7 +57,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }, async (args: { title: string }) => { const response = await handler(args.title); logger.debug('[hapiMCP] Response:', response); - + if (response.success) { return { content: [ @@ -77,19 +68,18 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH ], isError: false, }; - } else { - return { - content: [ - { - type: 'text' as const, - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; } - }); + return { + content: [ + { + type: 'text' as const, + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, + }, + ], + isError: true, + }; + }); mcp.registerTool('display_image', { description: 'Display a local image file inline in the current HAPI chat session', @@ -155,19 +145,58 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH } }); - const transport = new StreamableHTTPServerTransport({ - // NOTE: Returning session id here will result in claude - // sdk spawn to fail with `Invalid Request: Server already initialized` - sessionIdGenerator: undefined - }); - await mcp.connect(transport); + return mcp; +} - // - // Create the HTTP server - // +function readMcpSessionId(req: IncomingMessage): string | undefined { + const raw = req.headers['mcp-session-id']; + if (typeof raw === 'string') { + return raw; + } + if (Array.isArray(raw)) { + return raw[0]; + } + return undefined; +} + +export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { + const emitTitleSummary = options.emitTitleSummary ?? true; + const transports = new Map(); + const mcps = new Map(); + + const createMcpTransport = () => { + const mcp = createHapiMcpServer(client, emitTitleSummary); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports.set(sessionId, transport); + mcps.set(sessionId, mcp); + }, + onsessionclosed: (sessionId) => { + transports.delete(sessionId); + const server = mcps.get(sessionId); + mcps.delete(sessionId); + void server?.close(); + }, + }); + void mcp.connect(transport); + return transport; + }; const server = createServer(async (req, res) => { try { + const sessionId = readMcpSessionId(req); + const transport = sessionId + ? transports.get(sessionId) + : createMcpTransport(); + + if (!transport) { + if (!res.headersSent) { + res.writeHead(404).end(); + } + return; + } + await transport.handleRequest(req, res); } catch (error) { logger.debug("Error handling request:", error); @@ -184,13 +213,23 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }); }); + const mcpUrl = baseUrl.toString(); + client.updateMetadata((metadata) => ({ + ...metadata, + hapiMcpUrl: mcpUrl, + })); + return { - url: baseUrl.toString(), + url: mcpUrl, toolNames: ['change_title', 'display_image'], stop: () => { logger.debug('[hapiMCP] Stopping server'); - mcp.close(); + for (const mcp of mcps.values()) { + mcp.close(); + } + transports.clear(); + mcps.clear(); server.close(); } - } + }; } diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs new file mode 100644 index 00000000..c2670585 --- /dev/null +++ b/scripts/tooling/hapi-display-image.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env bun +/** + * Post a local image inline to a HAPI session via the session CLI's display_image MCP tool. + * + * Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP + * endpoint, not the session hook server on another loopback port in the same process. + * + * Usage: + * bun scripts/tooling/hapi-display-image.mjs [title] + */ + +import { readFileSync, lstatSync } from 'node:fs' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' +const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` + +const sessionArg = process.argv[2] +const imagePath = process.argv[3] +const title = process.argv[4] + +if (!sessionArg || !imagePath) { + console.error('usage: hapi-display-image.mjs [title]') + process.exit(2) +} + +if (!lstatSync(imagePath).isFile()) { + console.error(`not a file: ${imagePath}`) + process.exit(2) +} + +const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken +if (!token) { + console.error('missing CLI_API_TOKEN env and no cliApiToken in settings') + process.exit(2) +} +const authRes = await fetch(`${HAPI_HOST}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: token }), +}) +if (!authRes.ok) { + console.error('auth failed', authRes.status) + process.exit(3) +} +const { token: jwt } = await authRes.json() + +const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { + headers: { Authorization: `Bearer ${jwt}` }, +}) +const sessionsBody = await sessionsRes.json() +const sessions = sessionsBody.sessions ?? sessionsBody +const session = sessions.find((s) => s.id.startsWith(sessionArg)) +if (!session) { + console.error(`no session for prefix ${sessionArg}`) + process.exit(4) +} + +const mcpUrl = session.metadata?.hapiMcpUrl +if (!mcpUrl) { + console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP fix lands)') + process.exit(5) +} + +console.error(`hapi-display-image: session=${session.id} mcp=${mcpUrl}`) + +const client = new Client({ name: 'hapi-display-image', version: '1.0.0' }, { capabilities: {} }) +const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) +await client.connect(transport) +const result = await client.callTool({ + name: 'display_image', + arguments: { path: imagePath, title: title ?? undefined }, +}) +await client.close() +console.log(JSON.stringify(result, null, 2)) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1760337e..1b891fbe 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -56,6 +56,7 @@ export const MetadataSchema = z.object({ happyToolsDir: z.string().optional(), startedFromRunner: z.boolean().optional(), hostPid: z.number().optional(), + hapiMcpUrl: z.string().url().optional(), startedBy: z.enum(['runner', 'terminal']).optional(), lifecycleState: z.string().optional(), lifecycleStateSince: z.number().optional(),