mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user