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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/dist/api/client.d.ts +39 -0
  4. package/dist/api/client.js +49 -0
  5. package/dist/auth/credentials.d.ts +22 -0
  6. package/dist/auth/credentials.js +80 -0
  7. package/dist/auth/crypto.d.ts +118 -0
  8. package/dist/auth/crypto.js +249 -0
  9. package/dist/auth/pairing.d.ts +16 -0
  10. package/dist/auth/pairing.js +90 -0
  11. package/dist/auth/refresh.d.ts +11 -0
  12. package/dist/auth/refresh.js +50 -0
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.js +13 -0
  15. package/dist/errors.d.ts +15 -0
  16. package/dist/errors.js +30 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +306 -0
  19. package/dist/logger.d.ts +8 -0
  20. package/dist/logger.js +22 -0
  21. package/dist/relay/client.d.ts +34 -0
  22. package/dist/relay/client.js +242 -0
  23. package/dist/server.d.ts +16 -0
  24. package/dist/server.js +89 -0
  25. package/dist/session/keys.d.ts +25 -0
  26. package/dist/session/keys.js +41 -0
  27. package/dist/session/manager.d.ts +27 -0
  28. package/dist/session/manager.js +187 -0
  29. package/dist/session/types.d.ts +101 -0
  30. package/dist/session/types.js +1 -0
  31. package/dist/tools/answer_question.d.ts +5 -0
  32. package/dist/tools/answer_question.js +52 -0
  33. package/dist/tools/approve_permission.d.ts +4 -0
  34. package/dist/tools/approve_permission.js +54 -0
  35. package/dist/tools/deny_permission.d.ts +4 -0
  36. package/dist/tools/deny_permission.js +31 -0
  37. package/dist/tools/get_session.d.ts +4 -0
  38. package/dist/tools/get_session.js +106 -0
  39. package/dist/tools/list_computers.d.ts +4 -0
  40. package/dist/tools/list_computers.js +36 -0
  41. package/dist/tools/list_sessions.d.ts +4 -0
  42. package/dist/tools/list_sessions.js +46 -0
  43. package/dist/tools/send_message.d.ts +4 -0
  44. package/dist/tools/send_message.js +54 -0
  45. package/dist/tools/start_session.d.ts +5 -0
  46. package/dist/tools/start_session.js +49 -0
  47. package/dist/tools/watch_session.d.ts +4 -0
  48. package/dist/tools/watch_session.js +91 -0
  49. package/dist/types/wire.d.ts +148 -0
  50. package/dist/types/wire.js +9 -0
  51. package/package.json +66 -0
package/dist/server.js ADDED
@@ -0,0 +1,89 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerListComputers } from './tools/list_computers.js';
3
+ import { registerListSessions } from './tools/list_sessions.js';
4
+ import { registerGetSession } from './tools/get_session.js';
5
+ import { registerWatchSession } from './tools/watch_session.js';
6
+ import { registerSendMessage } from './tools/send_message.js';
7
+ import { registerApprovePermission } from './tools/approve_permission.js';
8
+ import { registerDenyPermission } from './tools/deny_permission.js';
9
+ import { registerAnswerQuestion } from './tools/answer_question.js';
10
+ import { registerStartSession } from './tools/start_session.js';
11
+ const AUTH_ERROR = {
12
+ isError: true,
13
+ content: [{
14
+ type: 'text',
15
+ text: 'Not authenticated. User must run `happy-mcp auth` in their terminal to authenticate before this tool is available. Have the user run this command and then try again.',
16
+ }],
17
+ };
18
+ const AUTH_RETRY = {
19
+ content: [{
20
+ type: 'text',
21
+ text: 'Authentication successful. Tools are now active. Please retry your request.',
22
+ }],
23
+ };
24
+ const TOOL_STUBS = [
25
+ ['list_computers', 'List available computers filtered by HAPPY_MCP_COMPUTERS.'],
26
+ ['list_sessions', 'List all active Happy Coder sessions.'],
27
+ ['get_session', 'Get detailed state of a specific session.'],
28
+ ['watch_session', 'Watch sessions for real-time updates.'],
29
+ ['send_message', 'Send a message to a session.'],
30
+ ['approve_permission', 'Approve a pending permission request.'],
31
+ ['deny_permission', 'Deny a pending permission request.'],
32
+ ['answer_question', 'Answer a question from a session.'],
33
+ ];
34
+ /**
35
+ * Create an MCP server in unauthenticated mode.
36
+ * All tools are registered as stubs that call the provided callback when invoked.
37
+ * The callback should attempt authentication and return true on success.
38
+ * Call activate() to swap in real tool handlers.
39
+ */
40
+ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticated) {
41
+ const server = new McpServer({
42
+ name: 'happy-mcp',
43
+ version: '0.1.0',
44
+ });
45
+ let stubRegistrations = [];
46
+ const stubHandler = async () => {
47
+ const activated = await onToolCallWhileUnauthenticated();
48
+ if (activated) {
49
+ return AUTH_RETRY;
50
+ }
51
+ return AUTH_ERROR;
52
+ };
53
+ function registerStubs() {
54
+ stubRegistrations = [];
55
+ for (const [name, desc] of TOOL_STUBS) {
56
+ stubRegistrations.push(server.tool(name, desc, {}, stubHandler));
57
+ }
58
+ if (config.enableStart) {
59
+ stubRegistrations.push(server.tool('start_session', 'Start a new Happy Coder session.', {}, stubHandler));
60
+ }
61
+ }
62
+ function registerReal(api, relay, sessionManager) {
63
+ registerListComputers(server, sessionManager, config);
64
+ registerListSessions(server, sessionManager, config);
65
+ registerGetSession(server, api, sessionManager);
66
+ registerWatchSession(server, relay, sessionManager);
67
+ registerSendMessage(server, api, sessionManager);
68
+ registerApprovePermission(server, relay, sessionManager);
69
+ registerDenyPermission(server, relay, sessionManager);
70
+ registerAnswerQuestion(server, api, relay, sessionManager);
71
+ if (config.enableStart) {
72
+ registerStartSession(server, api, relay, sessionManager);
73
+ }
74
+ }
75
+ // Start with stubs
76
+ registerStubs();
77
+ return {
78
+ server,
79
+ activate(api, relay, sessionManager) {
80
+ // Remove all stubs synchronously
81
+ for (const stub of stubRegistrations) {
82
+ stub.remove();
83
+ }
84
+ stubRegistrations = [];
85
+ // Register real tools
86
+ registerReal(api, relay, sessionManager);
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-session encryption key cache.
3
+ *
4
+ * Wraps resolveSessionEncryption() to avoid redundant NaCl box
5
+ * decryptions when the same dataEncryptionKey is seen multiple times
6
+ * (e.g., on reconnect catch-up or duplicate update events).
7
+ *
8
+ * Keys are cached by dataEncryptionKey string value. Sessions without
9
+ * a dataEncryptionKey (legacy) always return the account secret directly
10
+ * and don't need caching.
11
+ */
12
+ import { type SessionEncryption, type Credentials } from '../auth/crypto.js';
13
+ /**
14
+ * Resolve session encryption, using cache for previously-seen keys.
15
+ * This avoids repeated NaCl box decryptions for the same dataEncryptionKey.
16
+ */
17
+ export declare function resolveSessionEncryptionCached(dataEncryptionKey: string | null | undefined, credentials: Credentials): SessionEncryption;
18
+ /**
19
+ * Clear the key cache. Call on shutdown or credential rotation.
20
+ */
21
+ export declare function clearKeyCache(): void;
22
+ /**
23
+ * Get the current cache size (for diagnostics).
24
+ */
25
+ export declare function keyCacheSize(): number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-session encryption key cache.
3
+ *
4
+ * Wraps resolveSessionEncryption() to avoid redundant NaCl box
5
+ * decryptions when the same dataEncryptionKey is seen multiple times
6
+ * (e.g., on reconnect catch-up or duplicate update events).
7
+ *
8
+ * Keys are cached by dataEncryptionKey string value. Sessions without
9
+ * a dataEncryptionKey (legacy) always return the account secret directly
10
+ * and don't need caching.
11
+ */
12
+ import { resolveSessionEncryption } from '../auth/crypto.js';
13
+ const keyCache = new Map();
14
+ /**
15
+ * Resolve session encryption, using cache for previously-seen keys.
16
+ * This avoids repeated NaCl box decryptions for the same dataEncryptionKey.
17
+ */
18
+ export function resolveSessionEncryptionCached(dataEncryptionKey, credentials) {
19
+ if (!dataEncryptionKey) {
20
+ // Legacy: no caching needed, returns account secret directly
21
+ return resolveSessionEncryption(dataEncryptionKey, credentials);
22
+ }
23
+ const cached = keyCache.get(dataEncryptionKey);
24
+ if (cached)
25
+ return cached;
26
+ const encryption = resolveSessionEncryption(dataEncryptionKey, credentials);
27
+ keyCache.set(dataEncryptionKey, encryption);
28
+ return encryption;
29
+ }
30
+ /**
31
+ * Clear the key cache. Call on shutdown or credential rotation.
32
+ */
33
+ export function clearKeyCache() {
34
+ keyCache.clear();
35
+ }
36
+ /**
37
+ * Get the current cache size (for diagnostics).
38
+ */
39
+ export function keyCacheSize() {
40
+ return keyCache.size;
41
+ }
@@ -0,0 +1,27 @@
1
+ import type { CachedSession, MachineState, DecryptedMessage, SessionMetadata, AgentState, SessionStatus, PermissionRequest } from './types.js';
2
+ import type { SessionEncryption } from '../auth/crypto.js';
3
+ export declare class SessionManager {
4
+ private sessions;
5
+ private machines;
6
+ private cacheTtl;
7
+ private cleanupInterval;
8
+ constructor(cacheTtlSeconds?: number);
9
+ loadSession(id: string, encryption: SessionEncryption, metadata: SessionMetadata | null, metadataVersion: number, agentState: AgentState | null, agentStateVersion: number, active: boolean, createdAt: number, updatedAt: number): void;
10
+ get(id: string): CachedSession | undefined;
11
+ getAll(): CachedSession[];
12
+ has(id: string): boolean;
13
+ remove(id: string): void;
14
+ updateMetadata(id: string, metadata: SessionMetadata | null, version: number): void;
15
+ updateAgentState(id: string, agentState: AgentState | null, version: number): void;
16
+ applyMessage(sessionId: string, messageId: string, seq: number, content: unknown, createdAt: number): void;
17
+ getMessages(sessionId: string, limit?: number): DecryptedMessage[];
18
+ getSessionStatus(sessionId: string): SessionStatus;
19
+ getPendingPermissions(sessionId: string): PermissionRequest[];
20
+ loadMachine(state: MachineState): void;
21
+ getMachine(machineId: string): MachineState | undefined;
22
+ getAllMachines(): MachineState[];
23
+ updateMachineMetadata(machineId: string, metadata: unknown, version: number): void;
24
+ updateMachineDaemonState(machineId: string, daemonState: unknown, version: number): void;
25
+ private evictStale;
26
+ destroy(): void;
27
+ }
@@ -0,0 +1,187 @@
1
+ import { logger } from '../logger.js';
2
+ const MAX_MESSAGES_PER_SESSION = 500;
3
+ export class SessionManager {
4
+ sessions = new Map();
5
+ machines = new Map();
6
+ cacheTtl;
7
+ cleanupInterval;
8
+ constructor(cacheTtlSeconds = 300) {
9
+ this.cacheTtl = cacheTtlSeconds * 1000;
10
+ this.cleanupInterval = setInterval(() => this.evictStale(), 60_000);
11
+ this.cleanupInterval.unref();
12
+ }
13
+ // --- Session CRUD ---
14
+ loadSession(id, encryption, metadata, metadataVersion, agentState, agentStateVersion, active, createdAt, updatedAt) {
15
+ const existing = this.sessions.get(id);
16
+ if (existing) {
17
+ // Preserve messages, update metadata/agentState if newer
18
+ if (metadataVersion > existing.metadataVersion) {
19
+ existing.metadata = metadata;
20
+ existing.metadataVersion = metadataVersion;
21
+ }
22
+ if (agentStateVersion > existing.agentStateVersion) {
23
+ existing.agentState = agentState;
24
+ existing.agentStateVersion = agentStateVersion;
25
+ }
26
+ existing.active = active;
27
+ existing.encryption = encryption;
28
+ existing.lastActivity = Date.now();
29
+ return;
30
+ }
31
+ this.sessions.set(id, {
32
+ id,
33
+ encryption,
34
+ metadata,
35
+ metadataVersion,
36
+ agentState,
37
+ agentStateVersion,
38
+ messages: [],
39
+ lastSeq: 0,
40
+ lastActivity: Date.now(),
41
+ active,
42
+ createdAt,
43
+ updatedAt,
44
+ });
45
+ }
46
+ get(id) {
47
+ return this.sessions.get(id);
48
+ }
49
+ getAll() {
50
+ return Array.from(this.sessions.values());
51
+ }
52
+ has(id) {
53
+ return this.sessions.has(id);
54
+ }
55
+ remove(id) {
56
+ this.sessions.delete(id);
57
+ }
58
+ // --- Metadata/AgentState Updates ---
59
+ updateMetadata(id, metadata, version) {
60
+ const session = this.sessions.get(id);
61
+ if (!session)
62
+ return;
63
+ if (version > session.metadataVersion) {
64
+ session.metadata = metadata;
65
+ session.metadataVersion = version;
66
+ session.lastActivity = Date.now();
67
+ logger.debug(`Session ${id} metadata updated to v${version}`);
68
+ }
69
+ }
70
+ updateAgentState(id, agentState, version) {
71
+ const session = this.sessions.get(id);
72
+ if (!session)
73
+ return;
74
+ if (version > session.agentStateVersion) {
75
+ session.agentState = agentState;
76
+ session.agentStateVersion = version;
77
+ session.lastActivity = Date.now();
78
+ logger.debug(`Session ${id} agentState updated to v${version}`);
79
+ }
80
+ }
81
+ // --- Message Handling ---
82
+ applyMessage(sessionId, messageId, seq, content, createdAt) {
83
+ const session = this.sessions.get(sessionId);
84
+ if (!session)
85
+ return;
86
+ // Deduplicate by seq
87
+ if (session.messages.some(m => m.seq === seq))
88
+ return;
89
+ session.messages.push({ id: messageId, seq, content, createdAt });
90
+ session.lastSeq = Math.max(session.lastSeq, seq);
91
+ session.lastActivity = Date.now();
92
+ // Cap messages
93
+ if (session.messages.length > MAX_MESSAGES_PER_SESSION) {
94
+ session.messages = session.messages.slice(-MAX_MESSAGES_PER_SESSION);
95
+ }
96
+ }
97
+ getMessages(sessionId, limit) {
98
+ const session = this.sessions.get(sessionId);
99
+ if (!session)
100
+ return [];
101
+ const msgs = session.messages;
102
+ return limit ? msgs.slice(-limit) : msgs;
103
+ }
104
+ // --- Status Derivation ---
105
+ getSessionStatus(sessionId) {
106
+ const session = this.sessions.get(sessionId);
107
+ if (!session?.agentState)
108
+ return 'idle';
109
+ const state = session.agentState;
110
+ const hasRequests = state.requests && Object.keys(state.requests).length > 0;
111
+ if (hasRequests)
112
+ return 'waiting_permission';
113
+ if (state.controlledByUser)
114
+ return 'active';
115
+ return 'idle';
116
+ }
117
+ getPendingPermissions(sessionId) {
118
+ const session = this.sessions.get(sessionId);
119
+ if (!session?.agentState?.requests)
120
+ return [];
121
+ return Object.entries(session.agentState.requests).map(([id, req]) => ({
122
+ requestId: id,
123
+ tool: req.tool,
124
+ arguments: req.arguments,
125
+ createdAt: req.createdAt,
126
+ isQuestion: req.tool === 'AskUserQuestion',
127
+ }));
128
+ }
129
+ // --- Machine State ---
130
+ loadMachine(state) {
131
+ const existing = this.machines.get(state.machineId);
132
+ if (existing) {
133
+ if (state.metadataVersion > existing.metadataVersion) {
134
+ existing.metadata = state.metadata;
135
+ existing.metadataVersion = state.metadataVersion;
136
+ }
137
+ if (state.daemonStateVersion > existing.daemonStateVersion) {
138
+ existing.daemonState = state.daemonState;
139
+ existing.daemonStateVersion = state.daemonStateVersion;
140
+ }
141
+ existing.active = state.active;
142
+ existing.activeAt = state.activeAt;
143
+ existing.encryption = state.encryption;
144
+ return;
145
+ }
146
+ this.machines.set(state.machineId, { ...state });
147
+ }
148
+ getMachine(machineId) {
149
+ return this.machines.get(machineId);
150
+ }
151
+ getAllMachines() {
152
+ return Array.from(this.machines.values());
153
+ }
154
+ updateMachineMetadata(machineId, metadata, version) {
155
+ const machine = this.machines.get(machineId);
156
+ if (!machine)
157
+ return;
158
+ if (version > machine.metadataVersion) {
159
+ machine.metadata = metadata;
160
+ machine.metadataVersion = version;
161
+ }
162
+ }
163
+ updateMachineDaemonState(machineId, daemonState, version) {
164
+ const machine = this.machines.get(machineId);
165
+ if (!machine)
166
+ return;
167
+ if (version > machine.daemonStateVersion) {
168
+ machine.daemonState = daemonState;
169
+ machine.daemonStateVersion = version;
170
+ }
171
+ }
172
+ // --- Cleanup ---
173
+ evictStale() {
174
+ const now = Date.now();
175
+ for (const [id, session] of this.sessions) {
176
+ if (!session.active && now - session.lastActivity > this.cacheTtl) {
177
+ this.sessions.delete(id);
178
+ logger.debug(`Evicted stale session ${id}`);
179
+ }
180
+ }
181
+ }
182
+ destroy() {
183
+ clearInterval(this.cleanupInterval);
184
+ this.sessions.clear();
185
+ this.machines.clear();
186
+ }
187
+ }
@@ -0,0 +1,101 @@
1
+ import type { SessionEncryption } from '../auth/crypto.js';
2
+ export interface SessionMetadata {
3
+ path?: string;
4
+ host?: string;
5
+ version?: string;
6
+ os?: string;
7
+ machineId?: string;
8
+ name?: string;
9
+ summary?: {
10
+ text: string;
11
+ updatedAt: number;
12
+ };
13
+ tools?: string[];
14
+ homeDir?: string;
15
+ flavor?: string;
16
+ lifecycleState?: string;
17
+ [key: string]: unknown;
18
+ }
19
+ export interface AgentState {
20
+ controlledByUser?: boolean | null;
21
+ requests?: Record<string, {
22
+ tool: string;
23
+ arguments: unknown;
24
+ createdAt: number;
25
+ }>;
26
+ completedRequests?: Record<string, {
27
+ tool: string;
28
+ arguments: unknown;
29
+ createdAt: number;
30
+ completedAt: number;
31
+ status: 'canceled' | 'denied' | 'approved';
32
+ reason?: string;
33
+ mode?: string;
34
+ decision?: string;
35
+ allowTools?: string[];
36
+ }>;
37
+ }
38
+ export interface DecryptedMessage {
39
+ id: string;
40
+ seq: number;
41
+ content: unknown;
42
+ createdAt: number;
43
+ }
44
+ export interface CachedSession {
45
+ id: string;
46
+ encryption: SessionEncryption;
47
+ metadata: SessionMetadata | null;
48
+ metadataVersion: number;
49
+ agentState: AgentState | null;
50
+ agentStateVersion: number;
51
+ messages: DecryptedMessage[];
52
+ lastSeq: number;
53
+ lastActivity: number;
54
+ active: boolean;
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ }
58
+ export interface MachineState {
59
+ machineId: string;
60
+ metadata: unknown | null;
61
+ metadataVersion: number;
62
+ daemonState: unknown | null;
63
+ daemonStateVersion: number;
64
+ encryption: SessionEncryption | null;
65
+ active: boolean;
66
+ activeAt: number;
67
+ }
68
+ export interface RawSession {
69
+ id: string;
70
+ seq: number;
71
+ createdAt: number;
72
+ updatedAt: number;
73
+ active: boolean;
74
+ activeAt: number;
75
+ metadata: string;
76
+ metadataVersion: number;
77
+ agentState: string | null;
78
+ agentStateVersion: number;
79
+ dataEncryptionKey: string | null;
80
+ }
81
+ export interface RawMachine {
82
+ id: string;
83
+ metadata: string;
84
+ metadataVersion: number;
85
+ daemonState?: string | null;
86
+ daemonStateVersion?: number;
87
+ dataEncryptionKey?: string | null;
88
+ seq: number;
89
+ active: boolean;
90
+ activeAt: number;
91
+ createdAt: number;
92
+ updatedAt: number;
93
+ }
94
+ export type SessionStatus = 'active' | 'idle' | 'waiting_permission';
95
+ export interface PermissionRequest {
96
+ requestId: string;
97
+ tool: string;
98
+ arguments: unknown;
99
+ createdAt: number;
100
+ isQuestion: boolean;
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 registerAnswerQuestion(server: McpServer, api: ApiClient, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { randomUUID } from 'crypto';
3
+ import { encryptToBase64 } from '../auth/crypto.js';
4
+ export function registerAnswerQuestion(server, api, relay, sessionManager) {
5
+ return server.tool('answer_question', 'Answer a question (AskUserQuestion) from a session. Approves the pending permission and sends a user message with the answer.', {
6
+ sessionId: z.string().describe('The session ID'),
7
+ requestId: z.string().describe('The question request ID from pending permissions'),
8
+ answers: z.record(z.string(), z.string()).describe('Map of question header to selected answer'),
9
+ }, async ({ sessionId, requestId, answers }) => {
10
+ try {
11
+ // Check relay is connected (REQUIRED for answer_question)
12
+ if (!relay.connected) {
13
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay must be connected to answer questions' }) }] };
14
+ }
15
+ const session = sessionManager.get(sessionId);
16
+ if (!session) {
17
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Session ${sessionId} not found` }) }] };
18
+ }
19
+ // Validate request exists
20
+ const pending = sessionManager.getPendingPermissions(sessionId);
21
+ const request = pending.find(r => r.requestId === requestId);
22
+ if (!request) {
23
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RequestNotFound', message: `Request ${requestId} not found in pending permissions` }) }] };
24
+ }
25
+ // Validate it's a question
26
+ if (!request.isQuestion) {
27
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'NotAQuestion', message: `Request ${requestId} is not an AskUserQuestion. Use approve_permission instead.` }) }] };
28
+ }
29
+ // 1. Approve the permission request (MUST succeed)
30
+ await relay.sessionRpc(sessionId, 'permission', {
31
+ id: requestId,
32
+ approved: true,
33
+ decision: 'approved',
34
+ });
35
+ // 2. Format and send the answer as a user message
36
+ const answerText = Object.entries(answers)
37
+ .map(([header, selection]) => `${header}: ${selection}`)
38
+ .join('\n');
39
+ const content = {
40
+ role: 'user',
41
+ content: { type: 'text', text: answerText },
42
+ meta: { sentFrom: 'happy-mcp' },
43
+ };
44
+ const encrypted = encryptToBase64(session.encryption, content);
45
+ await api.sendMessages(sessionId, [{ content: encrypted, localId: randomUUID() }]);
46
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, requestId, answered: true }, null, 2) }] };
47
+ }
48
+ catch (err) {
49
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'AnswerQuestionFailed', message: err.message }) }] };
50
+ }
51
+ });
52
+ }
@@ -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 registerApprovePermission(server: McpServer, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { logger } from '../logger.js';
3
+ const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'];
4
+ const DECISIONS = ['approved', 'approved_for_session'];
5
+ export function registerApprovePermission(server, relay, sessionManager) {
6
+ return server.tool('approve_permission', 'Approve a pending permission request in a session. Permission requests are shown in get_session and watch_session results.', {
7
+ sessionId: z.string().describe('The session ID'),
8
+ requestId: z.string().describe('The permission request ID to approve, or "all_pending" to approve all'),
9
+ mode: z.enum(PERMISSION_MODES).optional().describe('Permission mode to set after approval'),
10
+ allowTools: z.array(z.string()).optional().describe('List of tools to allow'),
11
+ decision: z.enum(DECISIONS).optional().default('approved').describe('Approval decision type'),
12
+ }, async ({ sessionId, requestId, mode, allowTools, decision }) => {
13
+ try {
14
+ if (!relay.connected) {
15
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
16
+ }
17
+ const session = sessionManager.get(sessionId);
18
+ if (!session) {
19
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Session ${sessionId} not found` }) }] };
20
+ }
21
+ if (requestId === 'all_pending') {
22
+ const pending = sessionManager.getPendingPermissions(sessionId);
23
+ if (pending.length === 0) {
24
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'NoPendingRequests', message: 'No pending permission requests to approve' }) }] };
25
+ }
26
+ const results = [];
27
+ for (const req of pending) {
28
+ await relay.sessionRpc(sessionId, 'permission', {
29
+ id: req.requestId,
30
+ approved: true,
31
+ mode,
32
+ allowTools,
33
+ decision: decision ?? 'approved',
34
+ });
35
+ results.push(req.requestId);
36
+ }
37
+ logger.info(`[audit] Permission APPROVED ALL: session=${sessionId} count=${results.length} decision=${decision ?? 'approved'}${mode ? ` mode=${mode}` : ''}`);
38
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, approvedCount: results.length, requestIds: results, decision: decision ?? 'approved' }, null, 2) }] };
39
+ }
40
+ await relay.sessionRpc(sessionId, 'permission', {
41
+ id: requestId,
42
+ approved: true,
43
+ mode,
44
+ allowTools,
45
+ decision: decision ?? 'approved',
46
+ });
47
+ logger.info(`[audit] Permission APPROVED: session=${sessionId} request=${requestId} decision=${decision ?? 'approved'}${mode ? ` mode=${mode}` : ''}`);
48
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, requestId, decision: decision ?? 'approved' }, null, 2) }] };
49
+ }
50
+ catch (err) {
51
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'ApprovePermissionFailed', message: err.message }) }] };
52
+ }
53
+ });
54
+ }
@@ -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 registerDenyPermission(server: McpServer, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { logger } from '../logger.js';
3
+ export function registerDenyPermission(server, relay, sessionManager) {
4
+ return server.tool('deny_permission', 'Deny a pending permission request in a session. Can also abort the entire session.', {
5
+ sessionId: z.string().describe('The session ID'),
6
+ requestId: z.string().describe('The permission request ID to deny'),
7
+ reason: z.string().optional().describe('Feedback text explaining why the request was denied'),
8
+ decision: z.enum(['denied', 'abort']).optional().default('denied').describe('Denial type: denied (reject this request) or abort (stop the session)'),
9
+ }, async ({ sessionId, requestId, reason, decision }) => {
10
+ try {
11
+ if (!relay.connected) {
12
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
13
+ }
14
+ const session = sessionManager.get(sessionId);
15
+ if (!session) {
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Session ${sessionId} not found` }) }] };
17
+ }
18
+ await relay.sessionRpc(sessionId, 'permission', {
19
+ id: requestId,
20
+ approved: false,
21
+ reason,
22
+ decision: decision ?? 'denied',
23
+ });
24
+ logger.info(`[audit] Permission DENIED: session=${sessionId} request=${requestId} decision=${decision ?? 'denied'}${reason ? ` reason=${reason}` : ''}`);
25
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, requestId, decision: decision ?? 'denied' }, null, 2) }] };
26
+ }
27
+ catch (err) {
28
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'DenyPermissionFailed', message: err.message }) }] };
29
+ }
30
+ });
31
+ }
@@ -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 registerGetSession(server: McpServer, api: ApiClient, sessionManager: SessionManager): RegisteredTool;