mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
Hub
- independent teams.db (own user_version ladder); feature gate default OFF,
so a disabled hub never creates the file and team routes simply do not exist
- /api/teams: list/detail/by-session/memory/messages/spawn/tasks,
human message channel, team rename/archive/delete
- team runtime: spawn members via the existing runner RPC (worktree/yolo
options), peer message routing (mention/dm push, broadcast pull-only),
budgets (max members, message rate, reply chain depth), member-offline
announcements that wake the lead or escalate to the human
- team-attention notifications: in-app toast when visible, web push otherwise
- team memory dir {HAPI_HOME}/teams/<id>/ with charter.md template; read-only
browse API with path-traversal guards
CLI
- MCP tools: team_status / team_read / team_send / spawn_peer (claude, codex
bridge and ACP flavors); spawn_peer stays behind manual approval
- HAPI_TEAM_* env for spawned members; system-prompt block for Claude/Codex;
assignment brief carries team rules for flavors without prompt injection
- RUNNER_CAPABILITIES.agentTeam advertised to the hub
Web
- team group chat route with folded peer threads, key/task filters,
human composer, member/decision/system message cards
- side panel: live member status, interactive task board (create/status/
assignee), team memory browser, budget
- team create / add-member / settings dialogs; sidebar team section;
hub settings toggle for the feature (restart to apply)
- SSE team-updated invalidation
Zero impact when disabled: no teams.db, no routes, no tool or prompt changes.
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
/**
|
|
* HAPI MCP STDIO Bridge
|
|
*
|
|
* Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`.
|
|
* On invocation it forwards the tool call to an existing HAPI HTTP MCP server
|
|
* using the StreamableHTTPClientTransport.
|
|
*
|
|
* Configure the target HTTP MCP URL via env var `HAPI_HTTP_MCP_URL` or
|
|
* via CLI flag `--url <http://127.0.0.1:PORT>`.
|
|
*
|
|
* Note: This process must not print to stdout as it would break MCP STDIO.
|
|
*/
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { z } from 'zod';
|
|
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt';
|
|
import {
|
|
INSPECT_PEER_TOOL_DESCRIPTION,
|
|
PING_PEER_TOOL_DESCRIPTION,
|
|
SESSION_ID_PREFIX_PARAM_DESCRIPTION,
|
|
} from '@hapi/protocol/sessionCitation';
|
|
|
|
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'];
|
|
|
|
function parseArgs(argv: string[]): { url: string | null; toolNames: Set<string> } {
|
|
let url: string | null = null;
|
|
let toolNames = new Set(DEFAULT_TOOL_NAMES);
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--url' && i + 1 < argv.length) {
|
|
url = argv[i + 1];
|
|
i++;
|
|
} else if (a === '--tools' && i + 1 < argv.length) {
|
|
toolNames = new Set(argv[i + 1].split(',').map((name) => name.trim()).filter(Boolean));
|
|
i++;
|
|
}
|
|
}
|
|
return { url, toolNames };
|
|
}
|
|
|
|
export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
|
|
try {
|
|
// Resolve target HTTP MCP URL
|
|
const { url: urlFromArgs, toolNames } = parseArgs(argv);
|
|
const baseUrl = urlFromArgs || process.env.HAPI_HTTP_MCP_URL || '';
|
|
|
|
if (!baseUrl) {
|
|
// Write to stderr; never stdout.
|
|
process.stderr.write(
|
|
'[hapi-mcp] Missing target URL. Set HAPI_HTTP_MCP_URL or pass --url <http://127.0.0.1:PORT>\n'
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
let httpClient: Client | null = null;
|
|
|
|
async function ensureHttpClient(): Promise<Client> {
|
|
if (httpClient) return httpClient;
|
|
const client = new Client(
|
|
{ name: 'hapi-stdio-bridge', version: '1.0.0' },
|
|
{ capabilities: {} }
|
|
);
|
|
|
|
const transport = new StreamableHTTPClientTransport(new URL(baseUrl));
|
|
await client.connect(transport);
|
|
httpClient = client;
|
|
return client;
|
|
}
|
|
|
|
// Create STDIO MCP server
|
|
const server = new McpServer({
|
|
name: 'HAPI MCP Bridge',
|
|
version: '1.0.0',
|
|
});
|
|
|
|
// Register tools and forward to HTTP MCP
|
|
const changeTitleInputSchema: z.ZodTypeAny = z.object({
|
|
title: z.string().describe('The new title for the chat session'),
|
|
});
|
|
|
|
if (toolNames.has('change_title')) {
|
|
server.registerTool<any, any>(
|
|
'change_title',
|
|
{
|
|
description: 'Change the title of the current chat session',
|
|
title: 'Change Chat Title',
|
|
inputSchema: changeTitleInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'change_title', arguments: args });
|
|
// Pass-through response from HTTP server
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
|
|
const displayImageInputSchema: z.ZodTypeAny = z.object({
|
|
path: z.string().describe('Absolute filesystem path of the local image to display to the human user. This file is sent for user display, not provided to the model for image inspection'),
|
|
title: z.string().optional().describe('Optional display title or filename shown to the human user'),
|
|
});
|
|
|
|
if (toolNames.has('display_image')) {
|
|
server.registerTool<any, any>(
|
|
'display_image',
|
|
{
|
|
description: `Display a local image file to the human user inline in the current HAPI chat session. ${DISPLAY_IMAGE_PROMPT_CURSOR}`,
|
|
title: 'Display Image',
|
|
inputSchema: displayImageInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'display_image', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to display image: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const displayVideoInputSchema: z.ZodTypeAny = z.object({
|
|
path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'),
|
|
title: z.string().optional().describe('Optional display title or filename for the video'),
|
|
});
|
|
|
|
if (toolNames.has('display_video')) {
|
|
server.registerTool<any, any>(
|
|
'display_video',
|
|
{
|
|
description: `Display a local mp4 or webm file inline in the current HAPI chat session. ${DISPLAY_VIDEO_PROMPT_CURSOR}`,
|
|
title: 'Display Video',
|
|
inputSchema: displayVideoInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'display_video', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to display video: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const displayMediaInputSchema: z.ZodTypeAny = z.object({
|
|
path: z.string().describe('Local filesystem path of the media or file to send to the user'),
|
|
title: z.string().trim().min(1).max(255).optional().describe('Optional display title or filename'),
|
|
});
|
|
|
|
if (toolNames.has('display_media')) {
|
|
server.registerTool<any, any>(
|
|
'display_media',
|
|
{
|
|
description: `Send a local image, video, audio, or other file to the current HAPI chat session. ${DISPLAY_MEDIA_PROMPT_CURSOR}`,
|
|
title: 'Display Media',
|
|
inputSchema: displayMediaInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
return await client.callTool({ name: 'display_media', arguments: args }) as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Failed to display media: ${error instanceof Error ? error.message : String(error)}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const pingPeerInputSchema: z.ZodTypeAny = z.object({
|
|
sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION),
|
|
message: z.string().min(1).describe('Message text to deliver to the target session'),
|
|
});
|
|
|
|
if (toolNames.has('ping_peer')) {
|
|
server.registerTool<any, any>(
|
|
'ping_peer',
|
|
{
|
|
description: PING_PEER_TOOL_DESCRIPTION,
|
|
title: 'Ping Peer Session',
|
|
inputSchema: pingPeerInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'ping_peer', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to ping peer: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
|
|
sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION),
|
|
messageLimit: z.number().int().min(1).max(100).optional().describe(
|
|
'Recent message page size (default 30, max 100). Text snippets only.'
|
|
),
|
|
});
|
|
|
|
if (toolNames.has('inspect_peer')) {
|
|
server.registerTool<any, any>(
|
|
'inspect_peer',
|
|
{
|
|
description: INSPECT_PEER_TOOL_DESCRIPTION,
|
|
title: 'Inspect Peer Session',
|
|
inputSchema: inspectPeerInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'inspect_peer', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to inspect peer: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const listPeersInputSchema: z.ZodTypeAny = z.object({
|
|
limit: z.number().int().min(1).max(100).optional().describe(
|
|
'Max sessions to return (default 30, max 100). Newest updatedAt first.'
|
|
),
|
|
});
|
|
|
|
if (toolNames.has('list_peers')) {
|
|
server.registerTool<any, any>(
|
|
'list_peers',
|
|
{
|
|
description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.',
|
|
title: 'List Peer Sessions',
|
|
inputSchema: listPeersInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'list_peers', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to list peers: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const forwardTeamTool = async (name: string, args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name, arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `${name} failed: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
};
|
|
|
|
const TEAM_TOOL_DEFS: Array<{ name: string; title: string; description: string; inputSchema: z.ZodTypeAny }> = [
|
|
{
|
|
name: 'team_status',
|
|
title: 'Team Status',
|
|
description: 'Agent Team: show your team, members, pending tasks and remaining budget. Call this right after starting as a team member to pick up your assignment.',
|
|
inputSchema: z.object({}),
|
|
},
|
|
{
|
|
name: 'team_read',
|
|
title: 'Read Team Messages',
|
|
description: 'Agent Team: read the shared team message log. Broadcasts are pull-only: call this to catch up before starting work.',
|
|
inputSchema: z.object({
|
|
afterSeq: z.number().int().nonnegative().optional().describe('Return messages with seq greater than this'),
|
|
limit: z.number().int().positive().max(2000).optional().describe('Max messages to return (default 100)'),
|
|
}),
|
|
},
|
|
{
|
|
name: 'team_send',
|
|
title: 'Send Team Message',
|
|
description: 'Agent Team: send a message to teammates. to="all" (default) writes to the shared log only; to="<role or session id prefix>" or to="lead" wakes that member; to="human" notifies the human out-of-band (use it or kind="decision" when you need a human decision). Use inReplyTo=<seq> when answering a peer message.',
|
|
inputSchema: z.object({
|
|
text: z.string().min(1).describe('Message text'),
|
|
to: z.string().min(1).optional().describe('"all" (default), "lead", "human", or a member session id/prefix'),
|
|
kind: z.enum(['chat', 'status', 'question', 'task-update', 'decision']).optional(),
|
|
inReplyTo: z.number().int().positive().optional().describe('seq of the message you are replying to'),
|
|
}),
|
|
},
|
|
{
|
|
name: 'spawn_peer',
|
|
title: 'Spawn Team Peer',
|
|
description: 'Agent Team: spawn a new teammate session with a role and an initial task. Requires user approval.',
|
|
inputSchema: z.object({
|
|
role: z.string().min(1).describe('Role / display name, unique in the team'),
|
|
task: z.string().min(1).optional().describe('Initial task brief delivered to the new member'),
|
|
agent: z.string().min(1).optional().describe('Agent flavor (claude, codex, ...). Defaults to the caller flavor.'),
|
|
model: z.string().min(1).optional().describe('Optional model override'),
|
|
worktree: z.boolean().optional().describe('Run the member in an isolated git worktree'),
|
|
worktreeName: z.string().min(1).max(80).optional(),
|
|
}),
|
|
},
|
|
];
|
|
|
|
for (const def of TEAM_TOOL_DEFS) {
|
|
if (!toolNames.has(def.name)) continue;
|
|
server.registerTool<any, any>(
|
|
def.name,
|
|
{ description: def.description, title: def.title, inputSchema: def.inputSchema },
|
|
async (args: Record<string, unknown>) => forwardTeamTool(def.name, args)
|
|
);
|
|
}
|
|
|
|
const skillLookupInputSchema: z.ZodTypeAny = z.object({
|
|
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
|
|
});
|
|
|
|
if (toolNames.has('skill_lookup')) {
|
|
server.registerTool<any, any>(
|
|
'skill_lookup',
|
|
{
|
|
description: 'Load a HAPI skill by exact name. When a user message starts with $name, call this tool with that name before acting.',
|
|
title: 'Look Up Skill',
|
|
inputSchema: skillLookupInputSchema,
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
try {
|
|
const client = await ensureHttpClient();
|
|
const response = await client.callTool({ name: 'skill_lookup', arguments: args });
|
|
return response as any;
|
|
} catch (error) {
|
|
return {
|
|
content: [
|
|
{ type: 'text' as const, text: `Failed to look up skill: ${error instanceof Error ? error.message : String(error)}` },
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Start STDIO transport
|
|
const stdio = new StdioServerTransport();
|
|
await server.connect(stdio);
|
|
} catch (err) {
|
|
try {
|
|
process.stderr.write(`[hapi-mcp] Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
} finally {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|