happy-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/api/client.d.ts +39 -0
- package/dist/api/client.js +49 -0
- package/dist/auth/credentials.d.ts +22 -0
- package/dist/auth/credentials.js +80 -0
- package/dist/auth/crypto.d.ts +118 -0
- package/dist/auth/crypto.js +249 -0
- package/dist/auth/pairing.d.ts +16 -0
- package/dist/auth/pairing.js +90 -0
- package/dist/auth/refresh.d.ts +11 -0
- package/dist/auth/refresh.js +50 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +13 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +306 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.js +22 -0
- package/dist/relay/client.d.ts +34 -0
- package/dist/relay/client.js +242 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +89 -0
- package/dist/session/keys.d.ts +25 -0
- package/dist/session/keys.js +41 -0
- package/dist/session/manager.d.ts +27 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/types.d.ts +101 -0
- package/dist/session/types.js +1 -0
- package/dist/tools/answer_question.d.ts +5 -0
- package/dist/tools/answer_question.js +52 -0
- package/dist/tools/approve_permission.d.ts +4 -0
- package/dist/tools/approve_permission.js +54 -0
- package/dist/tools/deny_permission.d.ts +4 -0
- package/dist/tools/deny_permission.js +31 -0
- package/dist/tools/get_session.d.ts +4 -0
- package/dist/tools/get_session.js +106 -0
- package/dist/tools/list_computers.d.ts +4 -0
- package/dist/tools/list_computers.js +36 -0
- package/dist/tools/list_sessions.d.ts +4 -0
- package/dist/tools/list_sessions.js +46 -0
- package/dist/tools/send_message.d.ts +4 -0
- package/dist/tools/send_message.js +54 -0
- package/dist/tools/start_session.d.ts +5 -0
- package/dist/tools/start_session.js +49 -0
- package/dist/tools/watch_session.d.ts +4 -0
- package/dist/tools/watch_session.js +91 -0
- package/dist/types/wire.d.ts +148 -0
- package/dist/types/wire.js +9 -0
- package/package.json +66 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { decodeBase64, decrypt } from '../auth/crypto.js';
|
|
3
|
+
function renderMessage(msg) {
|
|
4
|
+
const envelope = msg.content;
|
|
5
|
+
if (!envelope)
|
|
6
|
+
return null;
|
|
7
|
+
// Handle SessionEnvelope format: { id, time, role, ev: { t, text, ... } }
|
|
8
|
+
const ev = envelope.ev;
|
|
9
|
+
if (ev && typeof ev.t === 'string') {
|
|
10
|
+
const role = envelope.role === 'user' ? 'user' : 'assistant';
|
|
11
|
+
let text = '';
|
|
12
|
+
switch (ev.t) {
|
|
13
|
+
case 'text':
|
|
14
|
+
text = (ev.text ?? '');
|
|
15
|
+
break;
|
|
16
|
+
case 'service':
|
|
17
|
+
text = `[service] ${(ev.text ?? '')}`;
|
|
18
|
+
break;
|
|
19
|
+
case 'tool-call-start': return { id: (envelope.id ?? msg.id), role: 'tool_use', content: `${ev.name}: ${(ev.description ?? ev.title ?? '')}`, timestamp: new Date((envelope.time ?? msg.createdAt)).toISOString() };
|
|
20
|
+
case 'tool-call-end': return { id: (envelope.id ?? msg.id), role: 'tool_result', content: `[tool completed: ${ev.call}]`, timestamp: new Date((envelope.time ?? msg.createdAt)).toISOString() };
|
|
21
|
+
case 'turn-start':
|
|
22
|
+
text = '[turn started]';
|
|
23
|
+
break;
|
|
24
|
+
case 'turn-end':
|
|
25
|
+
text = `[turn ended: ${ev.status}]`;
|
|
26
|
+
break;
|
|
27
|
+
case 'start':
|
|
28
|
+
text = ev.title ? `[session started: ${ev.title}]` : '[session started]';
|
|
29
|
+
break;
|
|
30
|
+
case 'stop':
|
|
31
|
+
text = '[session stopped]';
|
|
32
|
+
break;
|
|
33
|
+
default: text = JSON.stringify(ev);
|
|
34
|
+
}
|
|
35
|
+
return { id: (envelope.id ?? msg.id), role, content: text, timestamp: new Date((envelope.time ?? msg.createdAt)).toISOString() };
|
|
36
|
+
}
|
|
37
|
+
// Handle legacy flat format: { role, content }
|
|
38
|
+
const role = (envelope.role ?? 'unknown');
|
|
39
|
+
const rawContent = envelope.content;
|
|
40
|
+
let text = '';
|
|
41
|
+
if (typeof rawContent === 'string')
|
|
42
|
+
text = rawContent;
|
|
43
|
+
else if (rawContent && typeof rawContent === 'object' && 'text' in rawContent)
|
|
44
|
+
text = rawContent.text;
|
|
45
|
+
else
|
|
46
|
+
text = JSON.stringify(rawContent);
|
|
47
|
+
return { id: msg.id, role, content: text, timestamp: new Date(msg.createdAt).toISOString() };
|
|
48
|
+
}
|
|
49
|
+
export function registerGetSession(server, api, sessionManager) {
|
|
50
|
+
return server.tool('get_session', 'Get detailed information about a specific session including recent messages, metadata, and pending permissions.', {
|
|
51
|
+
sessionId: z.string().describe('The session ID to get details for'),
|
|
52
|
+
includeMessages: z.boolean().optional().default(true).describe('Whether to include messages'),
|
|
53
|
+
lastN: z.number().optional().default(20).describe('Number of recent messages to include'),
|
|
54
|
+
after: z.string().optional().describe('ISO 8601 timestamp — only return messages after this time'),
|
|
55
|
+
}, async ({ sessionId, includeMessages, lastN, after }) => {
|
|
56
|
+
try {
|
|
57
|
+
const session = sessionManager.get(sessionId);
|
|
58
|
+
if (!session) {
|
|
59
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Session ${sessionId} not found` }) }] };
|
|
60
|
+
}
|
|
61
|
+
// Fetch messages from REST if we don't have enough cached
|
|
62
|
+
if (includeMessages && session.messages.length < lastN) {
|
|
63
|
+
try {
|
|
64
|
+
const { messages } = await api.getSessionMessages(sessionId, 0, lastN);
|
|
65
|
+
for (const msg of messages) {
|
|
66
|
+
if (msg.content?.t === 'encrypted' && msg.content?.c) {
|
|
67
|
+
const decrypted = decrypt(session.encryption.key, session.encryption.variant, decodeBase64(msg.content.c));
|
|
68
|
+
if (decrypted !== null) {
|
|
69
|
+
sessionManager.applyMessage(sessionId, msg.id, msg.seq, decrypted, msg.createdAt);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Use cached messages if REST fails
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const meta = session.metadata;
|
|
79
|
+
const status = sessionManager.getSessionStatus(sessionId);
|
|
80
|
+
const permissions = sessionManager.getPendingPermissions(sessionId);
|
|
81
|
+
let renderedMessages;
|
|
82
|
+
if (includeMessages) {
|
|
83
|
+
let rawMessages = sessionManager.getMessages(sessionId, lastN);
|
|
84
|
+
if (after) {
|
|
85
|
+
const afterTime = new Date(after).getTime();
|
|
86
|
+
rawMessages = rawMessages.filter(m => m.createdAt > afterTime);
|
|
87
|
+
}
|
|
88
|
+
renderedMessages = rawMessages.map(renderMessage).filter((m) => m !== null);
|
|
89
|
+
}
|
|
90
|
+
const result = {
|
|
91
|
+
sessionId: session.id,
|
|
92
|
+
computer: (meta?.host ?? 'unknown'),
|
|
93
|
+
projectPath: (meta?.path ?? 'unknown'),
|
|
94
|
+
status,
|
|
95
|
+
startedAt: new Date(session.createdAt).toISOString(),
|
|
96
|
+
lastActivity: new Date(session.lastActivity).toISOString(),
|
|
97
|
+
pendingPermissions: permissions.length > 0 ? permissions : undefined,
|
|
98
|
+
messages: includeMessages ? renderedMessages : undefined,
|
|
99
|
+
};
|
|
100
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'GetSessionFailed', message: err.message }) }] };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { SessionManager } from '../session/manager.js';
|
|
3
|
+
import type { Config } from '../config.js';
|
|
4
|
+
export declare function registerListComputers(server: McpServer, sessionManager: SessionManager, config: Config): RegisteredTool;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function registerListComputers(server, sessionManager, config) {
|
|
2
|
+
return server.tool('list_computers', 'List available computers filtered by HAPPY_MCP_COMPUTERS. Shows hostname, online status, and active session count. Use before start_session to find available machines.', {}, async () => {
|
|
3
|
+
try {
|
|
4
|
+
const machines = sessionManager.getAllMachines()
|
|
5
|
+
.filter(m => m.active)
|
|
6
|
+
.filter(m => {
|
|
7
|
+
const meta = m.metadata;
|
|
8
|
+
const hostname = (meta?.host ?? meta?.displayName ?? '').toLowerCase();
|
|
9
|
+
return config.computers.some(c => c === '*' || c.toLowerCase() === hostname);
|
|
10
|
+
});
|
|
11
|
+
const result = machines.map(m => {
|
|
12
|
+
const meta = m.metadata;
|
|
13
|
+
const hostname = (meta?.host ?? meta?.displayName ?? 'unknown');
|
|
14
|
+
// Count active sessions for this machine matching project paths
|
|
15
|
+
const activeSessions = sessionManager.getAll().filter(s => {
|
|
16
|
+
if (!s.active)
|
|
17
|
+
return false;
|
|
18
|
+
const sMeta = s.metadata;
|
|
19
|
+
if ((sMeta?.host ?? '').toLowerCase() !== hostname.toLowerCase())
|
|
20
|
+
return false;
|
|
21
|
+
return config.projectPaths.some(p => p === '*' || (sMeta?.path ?? '').startsWith(p));
|
|
22
|
+
}).length;
|
|
23
|
+
return {
|
|
24
|
+
machineId: m.machineId,
|
|
25
|
+
hostname,
|
|
26
|
+
activeAt: new Date(m.activeAt).toISOString(),
|
|
27
|
+
activeSessions,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'ListComputersFailed', message: err.message }) }] };
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { SessionManager } from '../session/manager.js';
|
|
3
|
+
import type { Config } from '../config.js';
|
|
4
|
+
export declare function registerListSessions(server: McpServer, sessionManager: SessionManager, config: Config): RegisteredTool;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export function registerListSessions(server, sessionManager, config) {
|
|
3
|
+
return server.tool('list_sessions', 'List all active Happy Coder sessions. Shows session ID, project path, hostname, status (active/idle/waiting_permission), and pending permissions.', {
|
|
4
|
+
filter: z.object({
|
|
5
|
+
status: z.enum(['active', 'idle', 'waiting_permission']).optional().describe('Filter by session status'),
|
|
6
|
+
computer: z.string().optional().describe('Filter by computer hostname'),
|
|
7
|
+
projectPath: z.string().optional().describe('Filter by project path prefix'),
|
|
8
|
+
}).optional(),
|
|
9
|
+
}, async ({ filter }) => {
|
|
10
|
+
try {
|
|
11
|
+
const computerFilter = filter?.computer ? [filter.computer] : config.computers;
|
|
12
|
+
const pathFilter = filter?.projectPath ? [filter.projectPath] : config.projectPaths;
|
|
13
|
+
const sessions = sessionManager.getAll().filter(s => {
|
|
14
|
+
if (!s.active)
|
|
15
|
+
return false;
|
|
16
|
+
const meta = s.metadata;
|
|
17
|
+
const matchesComputer = computerFilter.some(c => c === '*' || c.toLowerCase() === (meta?.host ?? '').toLowerCase());
|
|
18
|
+
const matchesPath = pathFilter.some(p => p === '*' || (meta?.path ?? '').startsWith(p));
|
|
19
|
+
if (!matchesComputer || !matchesPath)
|
|
20
|
+
return false;
|
|
21
|
+
if (filter?.status && sessionManager.getSessionStatus(s.id) !== filter.status)
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
});
|
|
25
|
+
const result = sessions.map(s => {
|
|
26
|
+
const meta = s.metadata;
|
|
27
|
+
const status = sessionManager.getSessionStatus(s.id);
|
|
28
|
+
const permissions = sessionManager.getPendingPermissions(s.id);
|
|
29
|
+
return {
|
|
30
|
+
sessionId: s.id,
|
|
31
|
+
computer: (meta?.host ?? 'unknown'),
|
|
32
|
+
projectPath: (meta?.path ?? 'unknown'),
|
|
33
|
+
status,
|
|
34
|
+
lastActivity: new Date(s.lastActivity).toISOString(),
|
|
35
|
+
messageCount: s.messages.length,
|
|
36
|
+
currentTask: meta?.summary?.text,
|
|
37
|
+
pendingPermissions: permissions.length > 0 ? permissions : undefined,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'ListSessionsFailed', message: err.message }) }] };
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { ApiClient } from '../api/client.js';
|
|
3
|
+
import type { SessionManager } from '../session/manager.js';
|
|
4
|
+
export declare function registerSendMessage(server: McpServer, api: ApiClient, sessionManager: SessionManager): RegisteredTool;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { encryptToBase64 } from '../auth/crypto.js';
|
|
4
|
+
const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'];
|
|
5
|
+
export function registerSendMessage(server, api, sessionManager) {
|
|
6
|
+
return server.tool('send_message', 'Send a message to a Happy Coder session. The message will appear as a user message in the session. Can optionally change the permission mode.', {
|
|
7
|
+
sessionId: z.string().describe('The session ID to send the message to'),
|
|
8
|
+
message: z.string().describe('The message text to send'),
|
|
9
|
+
meta: z.object({
|
|
10
|
+
permissionMode: z.enum(PERMISSION_MODES).optional(),
|
|
11
|
+
allowedTools: z.array(z.string()).optional(),
|
|
12
|
+
disallowedTools: z.array(z.string()).optional(),
|
|
13
|
+
}).optional(),
|
|
14
|
+
}, async ({ sessionId, message, meta }) => {
|
|
15
|
+
try {
|
|
16
|
+
const session = sessionManager.get(sessionId);
|
|
17
|
+
if (!session) {
|
|
18
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Session ${sessionId} not found` }) }] };
|
|
19
|
+
}
|
|
20
|
+
const status = sessionManager.getSessionStatus(sessionId);
|
|
21
|
+
if (status === 'active') {
|
|
22
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotIdle', message: 'Cannot send message while session is actively generating' }) }] };
|
|
23
|
+
}
|
|
24
|
+
// Build message in legacy format (verified against upstream)
|
|
25
|
+
const content = {
|
|
26
|
+
role: 'user',
|
|
27
|
+
content: { type: 'text', text: message },
|
|
28
|
+
meta: {
|
|
29
|
+
sentFrom: 'happy-mcp',
|
|
30
|
+
...(meta?.permissionMode ? { permissionMode: meta.permissionMode } : {}),
|
|
31
|
+
...(meta?.allowedTools ? { allowedTools: meta.allowedTools } : {}),
|
|
32
|
+
...(meta?.disallowedTools ? { disallowedTools: meta.disallowedTools } : {}),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
// Encrypt and send via V3 REST API
|
|
36
|
+
const encrypted = encryptToBase64(session.encryption, content);
|
|
37
|
+
const localId = randomUUID();
|
|
38
|
+
const result = await api.sendMessages(sessionId, [{ content: encrypted, localId }]);
|
|
39
|
+
return {
|
|
40
|
+
content: [{
|
|
41
|
+
type: 'text',
|
|
42
|
+
text: JSON.stringify({
|
|
43
|
+
success: true,
|
|
44
|
+
messageId: result.messages?.[0]?.id,
|
|
45
|
+
seq: result.messages?.[0]?.seq,
|
|
46
|
+
}, null, 2),
|
|
47
|
+
}],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SendMessageFailed', message: err.message }) }] };
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { ApiClient } from '../api/client.js';
|
|
3
|
+
import type { RelayClient } from '../relay/client.js';
|
|
4
|
+
import type { SessionManager } from '../session/manager.js';
|
|
5
|
+
export declare function registerStartSession(server: McpServer, _api: ApiClient, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'];
|
|
3
|
+
export function registerStartSession(server, _api, relay, sessionManager) {
|
|
4
|
+
return server.tool('start_session', 'Start a new Happy Coder session on a remote machine. Requires the machine to be online. Use list_computers to find available machines.', {
|
|
5
|
+
computer: z.string().describe('The machine ID (from list_computers)'),
|
|
6
|
+
projectPath: z.string().describe('The working directory for the new session'),
|
|
7
|
+
initialMessage: z.string().optional().describe('Initial message to send after session starts'),
|
|
8
|
+
permissionMode: z.enum(PERMISSION_MODES).optional().describe('Permission mode for the session'),
|
|
9
|
+
agent: z.enum(['claude', 'codex', 'gemini']).optional().default('claude').describe('The AI agent to use'),
|
|
10
|
+
}, async ({ computer, projectPath, initialMessage, permissionMode, agent }) => {
|
|
11
|
+
try {
|
|
12
|
+
if (!relay.connected) {
|
|
13
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
|
|
14
|
+
}
|
|
15
|
+
const machine = sessionManager.getMachine(computer);
|
|
16
|
+
if (!machine) {
|
|
17
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MachineNotFound', message: `Machine ${computer} not found` }) }] };
|
|
18
|
+
}
|
|
19
|
+
if (!machine.active) {
|
|
20
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MachineOffline', message: `Machine ${computer} is not online` }) }] };
|
|
21
|
+
}
|
|
22
|
+
const result = await relay.machineRpc(computer, 'spawn-happy-session', {
|
|
23
|
+
type: 'spawn-in-directory',
|
|
24
|
+
directory: projectPath,
|
|
25
|
+
agent: agent ?? 'claude',
|
|
26
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
27
|
+
});
|
|
28
|
+
if (result?.type === 'success' && result.sessionId) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{
|
|
31
|
+
type: 'text',
|
|
32
|
+
text: JSON.stringify({
|
|
33
|
+
sessionId: result.sessionId,
|
|
34
|
+
status: 'starting',
|
|
35
|
+
...(initialMessage ? { note: 'Use send_message to send the initial message after session becomes active' } : {}),
|
|
36
|
+
}, null, 2),
|
|
37
|
+
}],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (result?.type === 'error') {
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SpawnFailed', message: result.errorMessage ?? 'Unknown error' }) }] };
|
|
42
|
+
}
|
|
43
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'UnexpectedResponse', message: JSON.stringify(result) }) }] };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'StartSessionFailed', message: err.message }) }] };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { RelayClient } from '../relay/client.js';
|
|
3
|
+
import type { SessionManager } from '../session/manager.js';
|
|
4
|
+
export declare function registerWatchSession(server: McpServer, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
|
|
3
|
+
export function registerWatchSession(server, relay, sessionManager) {
|
|
4
|
+
return server.tool('watch_session', 'Watch one or more sessions until any becomes idle or has pending permissions. Returns final state of the triggering session. Use after send_message or approve_permission to wait for the agent to finish.', {
|
|
5
|
+
sessionIds: z.array(z.string()).describe('One or more session IDs to watch'),
|
|
6
|
+
timeoutSeconds: z.number().optional().default(DEFAULT_TIMEOUT_SECONDS).describe('Max wait time in seconds (default 300)'),
|
|
7
|
+
}, async ({ sessionIds, timeoutSeconds }) => {
|
|
8
|
+
try {
|
|
9
|
+
// Validate all session IDs exist
|
|
10
|
+
const missing = sessionIds.filter(id => !sessionManager.get(id));
|
|
11
|
+
if (missing.length > 0) {
|
|
12
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Sessions not found: ${missing.join(', ')}` }) }] };
|
|
13
|
+
}
|
|
14
|
+
if (!relay.connected) {
|
|
15
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
|
|
16
|
+
}
|
|
17
|
+
const watchedSet = new Set(sessionIds);
|
|
18
|
+
// Check if any session is already in a terminal state
|
|
19
|
+
for (const sid of sessionIds) {
|
|
20
|
+
const status = sessionManager.getSessionStatus(sid);
|
|
21
|
+
if (status === 'idle' || status === 'waiting_permission') {
|
|
22
|
+
const permissions = sessionManager.getPendingPermissions(sid);
|
|
23
|
+
const messages = sessionManager.getMessages(sid, 5);
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: JSON.stringify({
|
|
28
|
+
triggeredBy: sid,
|
|
29
|
+
status,
|
|
30
|
+
waitedSeconds: 0,
|
|
31
|
+
timedOut: false,
|
|
32
|
+
pendingPermissions: permissions,
|
|
33
|
+
lastMessages: messages.map(m => ({ id: m.id, content: m.content, createdAt: new Date(m.createdAt).toISOString() })),
|
|
34
|
+
}, null, 2),
|
|
35
|
+
}],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Wait for state change on any watched session
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
42
|
+
const result = await new Promise((resolve) => {
|
|
43
|
+
const timeout = setTimeout(() => {
|
|
44
|
+
cleanup();
|
|
45
|
+
resolve({ triggeredBy: sessionIds[0], status: 'timeout', timedOut: true });
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
const onSessionUpdate = (sid) => {
|
|
48
|
+
if (!watchedSet.has(sid))
|
|
49
|
+
return;
|
|
50
|
+
const status = sessionManager.getSessionStatus(sid);
|
|
51
|
+
if (status === 'idle' || status === 'waiting_permission') {
|
|
52
|
+
cleanup();
|
|
53
|
+
resolve({ triggeredBy: sid, status, timedOut: false });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const onDeleted = (sid) => {
|
|
57
|
+
if (!watchedSet.has(sid))
|
|
58
|
+
return;
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve({ triggeredBy: sid, status: 'deleted', timedOut: false });
|
|
61
|
+
};
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
relay.removeListener('session_update', onSessionUpdate);
|
|
65
|
+
relay.removeListener('session_deleted', onDeleted);
|
|
66
|
+
};
|
|
67
|
+
relay.on('session_update', onSessionUpdate);
|
|
68
|
+
relay.on('session_deleted', onDeleted);
|
|
69
|
+
});
|
|
70
|
+
const waitedSeconds = Math.round((Date.now() - startTime) / 1000);
|
|
71
|
+
const permissions = sessionManager.getPendingPermissions(result.triggeredBy);
|
|
72
|
+
const messages = sessionManager.getMessages(result.triggeredBy, 5);
|
|
73
|
+
return {
|
|
74
|
+
content: [{
|
|
75
|
+
type: 'text',
|
|
76
|
+
text: JSON.stringify({
|
|
77
|
+
triggeredBy: result.triggeredBy,
|
|
78
|
+
status: result.status,
|
|
79
|
+
waitedSeconds,
|
|
80
|
+
timedOut: result.timedOut,
|
|
81
|
+
pendingPermissions: permissions,
|
|
82
|
+
lastMessages: messages.map(m => ({ id: m.id, content: m.content, createdAt: new Date(m.createdAt).toISOString() })),
|
|
83
|
+
}, null, 2),
|
|
84
|
+
}],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'WatchSessionFailed', message: err.message }) }] };
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol types inlined from @slopus/happy-wire.
|
|
3
|
+
* These mirror the canonical schemas from packages/happy-wire/src/messages.ts
|
|
4
|
+
* and packages/happy-wire/src/messageMeta.ts.
|
|
5
|
+
*
|
|
6
|
+
* When @slopus/happy-wire is published as an npm package, replace these
|
|
7
|
+
* with imports from the package.
|
|
8
|
+
*/
|
|
9
|
+
export interface CoreUpdateContainer {
|
|
10
|
+
id: string;
|
|
11
|
+
seq: number;
|
|
12
|
+
body: UpdateBody;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
export type UpdateBody = NewMessageBody | UpdateSessionBody | UpdateMachineBody | NewSessionBody | DeleteSessionBody;
|
|
16
|
+
export interface NewMessageBody {
|
|
17
|
+
t: 'new-message';
|
|
18
|
+
sid: string;
|
|
19
|
+
message: WireMessage;
|
|
20
|
+
}
|
|
21
|
+
export interface UpdateSessionBody {
|
|
22
|
+
t: 'update-session';
|
|
23
|
+
id: string;
|
|
24
|
+
metadata?: VersionedField;
|
|
25
|
+
agentState?: VersionedField;
|
|
26
|
+
active?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface UpdateMachineBody {
|
|
29
|
+
t: 'update-machine';
|
|
30
|
+
machineId: string;
|
|
31
|
+
metadata?: VersionedField;
|
|
32
|
+
daemonState?: VersionedField;
|
|
33
|
+
active?: boolean;
|
|
34
|
+
activeAt?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface NewSessionBody {
|
|
37
|
+
t: 'new-session';
|
|
38
|
+
id: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
export interface DeleteSessionBody {
|
|
42
|
+
t: 'delete-session';
|
|
43
|
+
id: string;
|
|
44
|
+
}
|
|
45
|
+
export interface VersionedField {
|
|
46
|
+
version: number;
|
|
47
|
+
value: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface WireMessage {
|
|
50
|
+
id: string;
|
|
51
|
+
seq: number;
|
|
52
|
+
content: WireMessageContent;
|
|
53
|
+
localId?: string;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
updatedAt: number;
|
|
56
|
+
}
|
|
57
|
+
export interface WireMessageContent {
|
|
58
|
+
t: 'encrypted';
|
|
59
|
+
c: string;
|
|
60
|
+
}
|
|
61
|
+
/** User message format (legacy protocol, verified against upstream) */
|
|
62
|
+
export interface LegacyUserMessage {
|
|
63
|
+
role: 'user';
|
|
64
|
+
content: {
|
|
65
|
+
type: 'text';
|
|
66
|
+
text: string;
|
|
67
|
+
};
|
|
68
|
+
meta: LegacyMessageMeta;
|
|
69
|
+
}
|
|
70
|
+
export interface LegacyMessageMeta {
|
|
71
|
+
sentFrom: string;
|
|
72
|
+
permissionMode?: PermissionMode;
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo';
|
|
76
|
+
/** RPC call sent via Socket.io emitWithAck('rpc-call', ...) */
|
|
77
|
+
export interface RpcCall {
|
|
78
|
+
method: string;
|
|
79
|
+
params: string;
|
|
80
|
+
}
|
|
81
|
+
/** RPC response from server */
|
|
82
|
+
export interface RpcResponse {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
result?: string;
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
/** Permission RPC params (decrypted) */
|
|
88
|
+
export interface PermissionRpcParams {
|
|
89
|
+
id: string;
|
|
90
|
+
approved: boolean;
|
|
91
|
+
mode?: PermissionMode;
|
|
92
|
+
allowTools?: string[];
|
|
93
|
+
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
|
|
94
|
+
}
|
|
95
|
+
/** Machine RPC spawn params */
|
|
96
|
+
export interface SpawnSessionParams {
|
|
97
|
+
type: 'spawn-in-directory';
|
|
98
|
+
directory: string;
|
|
99
|
+
agent?: 'claude' | 'codex' | 'gemini';
|
|
100
|
+
}
|
|
101
|
+
/** Machine RPC spawn response */
|
|
102
|
+
export interface SpawnSessionResponse {
|
|
103
|
+
type: 'success' | 'error';
|
|
104
|
+
sessionId?: string;
|
|
105
|
+
errorMessage?: string;
|
|
106
|
+
}
|
|
107
|
+
/** POST /v3/sessions/:id/messages request body */
|
|
108
|
+
export interface SendMessagesRequest {
|
|
109
|
+
messages: Array<{
|
|
110
|
+
content: string;
|
|
111
|
+
localId: string;
|
|
112
|
+
}>;
|
|
113
|
+
}
|
|
114
|
+
/** POST /v3/sessions/:id/messages response */
|
|
115
|
+
export interface SendMessagesResponse {
|
|
116
|
+
messages: Array<{
|
|
117
|
+
id: string;
|
|
118
|
+
seq: number;
|
|
119
|
+
localId: string;
|
|
120
|
+
createdAt: number;
|
|
121
|
+
updatedAt: number;
|
|
122
|
+
}>;
|
|
123
|
+
}
|
|
124
|
+
/** GET /v3/sessions/:id/messages response */
|
|
125
|
+
export interface GetMessagesResponse {
|
|
126
|
+
messages: WireMessage[];
|
|
127
|
+
hasMore: boolean;
|
|
128
|
+
}
|
|
129
|
+
/** POST /v1/auth request/response */
|
|
130
|
+
export interface AuthChallengeRequest {
|
|
131
|
+
challenge: string;
|
|
132
|
+
publicKey: string;
|
|
133
|
+
signature: string;
|
|
134
|
+
}
|
|
135
|
+
export interface AuthChallengeResponse {
|
|
136
|
+
success: boolean;
|
|
137
|
+
token: string;
|
|
138
|
+
}
|
|
139
|
+
/** POST /v1/auth/account/request response */
|
|
140
|
+
export interface PairingResponse {
|
|
141
|
+
state: 'pending' | 'authorized' | 'rejected';
|
|
142
|
+
token?: string;
|
|
143
|
+
response?: string;
|
|
144
|
+
}
|
|
145
|
+
export interface SocketAuth {
|
|
146
|
+
token: string;
|
|
147
|
+
clientType: 'user-scoped';
|
|
148
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol types inlined from @slopus/happy-wire.
|
|
3
|
+
* These mirror the canonical schemas from packages/happy-wire/src/messages.ts
|
|
4
|
+
* and packages/happy-wire/src/messageMeta.ts.
|
|
5
|
+
*
|
|
6
|
+
* When @slopus/happy-wire is published as an npm package, replace these
|
|
7
|
+
* with imports from the package.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "happy-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for observing and controlling Happy Coder sessions",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Jared Spencer",
|
|
7
|
+
"email": "jared@jklz.dev"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/jklz/happy-mcp-server.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/jklz/happy-mcp-server/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/jklz/happy-mcp-server#readme",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"happy-coder",
|
|
22
|
+
"ai",
|
|
23
|
+
"claude",
|
|
24
|
+
"session-management"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "dist/index.js",
|
|
33
|
+
"bin": {
|
|
34
|
+
"happy-mcp": "dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
38
|
+
"dev": "tsx src/index.ts",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"test:coverage": "vitest --coverage",
|
|
42
|
+
"lint": "eslint src",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.6.0",
|
|
50
|
+
"axios": "^1.7.0",
|
|
51
|
+
"qrcode-terminal": "^0.12.0",
|
|
52
|
+
"socket.io-client": "^4.7.0",
|
|
53
|
+
"tweetnacl": "^1.0.3",
|
|
54
|
+
"zod": "^3.23.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^9.0.0",
|
|
58
|
+
"@types/node": "^20.0.0",
|
|
59
|
+
"@types/qrcode-terminal": "^0.12.0",
|
|
60
|
+
"@vitest/coverage-v8": "^2.0.0",
|
|
61
|
+
"tsx": "^4.0.0",
|
|
62
|
+
"typescript": "^5.5.0",
|
|
63
|
+
"typescript-eslint": "^8.0.0",
|
|
64
|
+
"vitest": "^2.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|