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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-18 10:11:18 +08:00
committed by GitHub
co-authored by Cursor
parent b3add07ad7
commit 4bc3393904
3 changed files with 152 additions and 36 deletions
+75 -36
View File
@@ -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<any, any>('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<string, StreamableHTTPServerTransport>();
const mcps = new Map<string, McpServer>();
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();
}
}
};
}
+76
View File
@@ -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 <session-id-prefix> <image-path> [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 <session-id-prefix> <image-path> [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))
+1
View File
@@ -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(),