Files
hapi/scripts/tooling/hapi-display-image.mjs
T
4bc3393904 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>
2026-06-18 10:11:18 +08:00

77 lines
2.6 KiB
JavaScript

#!/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))