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
package/dist/index.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { setupLogger, logger } from './logger.js';
|
|
5
|
+
import { performPairing } from './auth/pairing.js';
|
|
6
|
+
import { readCredentials, validateFilePermissions, clearCredentials } from './auth/credentials.js';
|
|
7
|
+
import { decryptFromBase64 } from './auth/crypto.js';
|
|
8
|
+
import { resolveSessionEncryptionCached, clearKeyCache } from './session/keys.js';
|
|
9
|
+
import { SessionManager } from './session/manager.js';
|
|
10
|
+
import { ApiClient } from './api/client.js';
|
|
11
|
+
import { RelayClient } from './relay/client.js';
|
|
12
|
+
import { createUnauthenticatedServer } from './server.js';
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function sessionMatchesFilters(metadata, config) {
|
|
17
|
+
if (!metadata)
|
|
18
|
+
return true; // can't filter without metadata
|
|
19
|
+
const matchesComputer = config.computers.some(c => c === '*' || c.toLowerCase() === (metadata.host ?? '').toLowerCase());
|
|
20
|
+
const matchesPath = config.projectPaths.some(p => p === '*' || (metadata.path ?? '').startsWith(p));
|
|
21
|
+
return matchesComputer && matchesPath;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build the full authenticated runtime: API client, session manager, relay.
|
|
25
|
+
* Extracted so it can be called from both initial startup and hot auth.
|
|
26
|
+
*/
|
|
27
|
+
async function initializeAuthenticatedState(config, credentials) {
|
|
28
|
+
const sessionManager = new SessionManager(config.sessionCacheTtl);
|
|
29
|
+
const api = new ApiClient(credentials, config);
|
|
30
|
+
// Fetch initial sessions
|
|
31
|
+
try {
|
|
32
|
+
const rawSessions = await api.listActiveSessions();
|
|
33
|
+
logger.info(`Fetched ${rawSessions.length} active sessions`);
|
|
34
|
+
for (const raw of rawSessions) {
|
|
35
|
+
try {
|
|
36
|
+
const encryption = resolveSessionEncryptionCached(raw.dataEncryptionKey, credentials);
|
|
37
|
+
const metadata = raw.metadata
|
|
38
|
+
? decryptFromBase64(encryption, raw.metadata)
|
|
39
|
+
: null;
|
|
40
|
+
const agentState = raw.agentState
|
|
41
|
+
? decryptFromBase64(encryption, raw.agentState)
|
|
42
|
+
: null;
|
|
43
|
+
if (!sessionMatchesFilters(metadata, config)) {
|
|
44
|
+
logger.debug(`Skipping session ${raw.id} -- doesn't match filters`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
sessionManager.loadSession(raw.id, encryption, metadata, raw.metadataVersion, agentState, raw.agentStateVersion, raw.active, raw.createdAt, raw.updatedAt);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
logger.warn(`Failed to load session ${raw.id}:`, err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
logger.info(`Loaded ${sessionManager.getAll().length} sessions into cache`);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
logger.error('Failed to fetch initial sessions:', err.message);
|
|
57
|
+
}
|
|
58
|
+
// Fetch machines
|
|
59
|
+
try {
|
|
60
|
+
const rawMachines = await api.listMachines();
|
|
61
|
+
logger.info(`Fetched ${rawMachines.length} machines`);
|
|
62
|
+
for (const raw of rawMachines) {
|
|
63
|
+
try {
|
|
64
|
+
const encryption = resolveSessionEncryptionCached(raw.dataEncryptionKey ?? null, credentials);
|
|
65
|
+
const metadata = raw.metadata
|
|
66
|
+
? decryptFromBase64(encryption, raw.metadata)
|
|
67
|
+
: null;
|
|
68
|
+
sessionManager.loadMachine({
|
|
69
|
+
machineId: raw.id, metadata,
|
|
70
|
+
metadataVersion: raw.metadataVersion,
|
|
71
|
+
daemonState: null,
|
|
72
|
+
daemonStateVersion: raw.daemonStateVersion ?? 0,
|
|
73
|
+
encryption, active: raw.active, activeAt: raw.activeAt,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger.warn(`Failed to load machine ${raw.id}:`, err.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
logger.info(`Loaded ${sessionManager.getAllMachines().length} machines into cache`);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
logger.error('Failed to fetch machines:', err.message);
|
|
84
|
+
}
|
|
85
|
+
// Connect relay
|
|
86
|
+
const relay = new RelayClient(credentials, sessionManager, config);
|
|
87
|
+
try {
|
|
88
|
+
await relay.connect();
|
|
89
|
+
logger.info('Relay connected');
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
logger.error('Failed to connect relay:', err.message);
|
|
93
|
+
}
|
|
94
|
+
// Reconnection catch-up
|
|
95
|
+
relay.on('connected', async () => {
|
|
96
|
+
try {
|
|
97
|
+
const rawSessions = await api.listActiveSessions();
|
|
98
|
+
logger.info(`Reconnect catch-up: fetched ${rawSessions.length} sessions`);
|
|
99
|
+
for (const raw of rawSessions) {
|
|
100
|
+
try {
|
|
101
|
+
const encryption = resolveSessionEncryptionCached(raw.dataEncryptionKey, credentials);
|
|
102
|
+
const metadata = raw.metadata
|
|
103
|
+
? decryptFromBase64(encryption, raw.metadata)
|
|
104
|
+
: null;
|
|
105
|
+
const agentState = raw.agentState
|
|
106
|
+
? decryptFromBase64(encryption, raw.agentState)
|
|
107
|
+
: null;
|
|
108
|
+
if (!sessionMatchesFilters(metadata, config))
|
|
109
|
+
continue;
|
|
110
|
+
sessionManager.loadSession(raw.id, encryption, metadata, raw.metadataVersion, agentState, raw.agentStateVersion, raw.active, raw.createdAt, raw.updatedAt);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
logger.warn(`Catch-up: failed to load session ${raw.id}:`, err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
logger.warn('Reconnect catch-up failed:', err.message);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
relay.on('disconnected', () => {
|
|
122
|
+
relay.updateToken(credentials.token);
|
|
123
|
+
});
|
|
124
|
+
return { api, relay, sessionManager };
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// CLI Commands
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
function printHelp() {
|
|
130
|
+
console.error(`Usage: happy-mcp [command]
|
|
131
|
+
|
|
132
|
+
Commands:
|
|
133
|
+
(no args) Start the MCP server (stdio transport)
|
|
134
|
+
auth Check auth status, or pair if not authenticated
|
|
135
|
+
auth login Run the QR pairing flow (re-auth if already paired)
|
|
136
|
+
auth logout Remove saved credentials
|
|
137
|
+
help Show this help message
|
|
138
|
+
`);
|
|
139
|
+
}
|
|
140
|
+
async function runAuth() {
|
|
141
|
+
const config = loadConfig();
|
|
142
|
+
const creds = readCredentials(config.credentialsPath);
|
|
143
|
+
if (creds) {
|
|
144
|
+
console.error('[happy-mcp] Already authenticated.');
|
|
145
|
+
console.error(' To login with a different account: happy-mcp auth login');
|
|
146
|
+
console.error(' To logout: happy-mcp auth logout');
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
await runAuthLogin();
|
|
150
|
+
}
|
|
151
|
+
async function runAuthLogin() {
|
|
152
|
+
const config = loadConfig();
|
|
153
|
+
setupLogger('info');
|
|
154
|
+
await performPairing(config);
|
|
155
|
+
console.error('[happy-mcp] Authentication complete.');
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
async function runAuthLogout() {
|
|
159
|
+
const config = loadConfig();
|
|
160
|
+
setupLogger(config.logLevel);
|
|
161
|
+
const creds = readCredentials(config.credentialsPath);
|
|
162
|
+
if (!creds) {
|
|
163
|
+
console.error('[happy-mcp] Not currently authenticated. Nothing to do.');
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
clearCredentials(config.credentialsPath);
|
|
167
|
+
console.error('[happy-mcp] Logged out. Credentials removed.');
|
|
168
|
+
console.error(' To re-authenticate: happy-mcp auth login');
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// MCP Server
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
async function runServer() {
|
|
175
|
+
const config = loadConfig();
|
|
176
|
+
setupLogger(config.logLevel);
|
|
177
|
+
logger.info('Starting happy-mcp...');
|
|
178
|
+
const authState = {
|
|
179
|
+
authenticated: false,
|
|
180
|
+
activatingPromise: null,
|
|
181
|
+
relay: null,
|
|
182
|
+
sessionManager: null,
|
|
183
|
+
};
|
|
184
|
+
let activateFn = null;
|
|
185
|
+
async function tryActivate() {
|
|
186
|
+
if (authState.authenticated)
|
|
187
|
+
return true;
|
|
188
|
+
if (authState.activatingPromise)
|
|
189
|
+
return authState.activatingPromise;
|
|
190
|
+
const creds = readCredentials(config.credentialsPath);
|
|
191
|
+
if (!creds)
|
|
192
|
+
return false;
|
|
193
|
+
try {
|
|
194
|
+
validateFilePermissions(config.credentialsPath);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
logger.error('Credential file has unsafe permissions:', err.message);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
authState.activatingPromise = (async () => {
|
|
201
|
+
try {
|
|
202
|
+
const state = await initializeAuthenticatedState(config, creds);
|
|
203
|
+
authState.relay = state.relay;
|
|
204
|
+
authState.sessionManager = state.sessionManager;
|
|
205
|
+
activateFn(state.api, state.relay, state.sessionManager);
|
|
206
|
+
authState.authenticated = true;
|
|
207
|
+
logger.info('Lazy authentication complete -- tools are now active');
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
logger.error('Lazy auth failed:', err.message);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
authState.activatingPromise = null;
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
return authState.activatingPromise;
|
|
219
|
+
}
|
|
220
|
+
const { server, activate } = createUnauthenticatedServer(config, tryActivate);
|
|
221
|
+
activateFn = activate;
|
|
222
|
+
// Startup optimization: check for existing credentials
|
|
223
|
+
const initialCreds = readCredentials(config.credentialsPath);
|
|
224
|
+
if (initialCreds) {
|
|
225
|
+
try {
|
|
226
|
+
validateFilePermissions(config.credentialsPath);
|
|
227
|
+
logger.info('Credentials found at startup');
|
|
228
|
+
const state = await initializeAuthenticatedState(config, initialCreds);
|
|
229
|
+
authState.relay = state.relay;
|
|
230
|
+
authState.sessionManager = state.sessionManager;
|
|
231
|
+
activate(state.api, state.relay, state.sessionManager);
|
|
232
|
+
authState.authenticated = true;
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
logger.error('Failed to initialize with existing credentials:', err.message);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
logger.warn('No credentials found. Tools will attempt auth on each call.');
|
|
240
|
+
logger.warn('Run `happy-mcp auth` to authenticate.');
|
|
241
|
+
}
|
|
242
|
+
const transport = new StdioServerTransport();
|
|
243
|
+
await server.connect(transport);
|
|
244
|
+
logger.info(`MCP server started on stdio (${authState.authenticated ? 'authenticated' : 'unauthenticated'} mode)`);
|
|
245
|
+
const shutdown = async () => {
|
|
246
|
+
logger.info('Shutting down...');
|
|
247
|
+
authState.relay?.disconnect();
|
|
248
|
+
authState.sessionManager?.destroy();
|
|
249
|
+
clearKeyCache();
|
|
250
|
+
await server.close();
|
|
251
|
+
process.exit(0);
|
|
252
|
+
};
|
|
253
|
+
process.on('SIGINT', shutdown);
|
|
254
|
+
process.on('SIGTERM', shutdown);
|
|
255
|
+
process.on('uncaughtException', (err) => {
|
|
256
|
+
logger.error('Uncaught exception:', err.message);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
});
|
|
259
|
+
process.on('unhandledRejection', (reason) => {
|
|
260
|
+
logger.error('Unhandled rejection:', reason);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// CLI Router
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
const command = process.argv[2];
|
|
268
|
+
if (!command) {
|
|
269
|
+
runServer().catch((err) => {
|
|
270
|
+
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else if (command === 'auth') {
|
|
275
|
+
const subcommand = process.argv[3];
|
|
276
|
+
if (!subcommand) {
|
|
277
|
+
runAuth().catch((err) => {
|
|
278
|
+
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
else if (subcommand === 'login') {
|
|
283
|
+
runAuthLogin().catch((err) => {
|
|
284
|
+
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else if (subcommand === 'logout') {
|
|
289
|
+
runAuthLogout().catch((err) => {
|
|
290
|
+
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
console.error(`[happy-mcp] Unknown auth command '${subcommand}'. Run \`happy-mcp help\` for help.`);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (command === 'help') {
|
|
300
|
+
printHelp();
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.error(`[happy-mcp] Unknown command '${command}'. Run \`happy-mcp help\` for help.`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LogLevel } from './config.js';
|
|
2
|
+
export declare function setupLogger(level: LogLevel): void;
|
|
3
|
+
export declare const logger: {
|
|
4
|
+
debug: (...args: unknown[]) => void;
|
|
5
|
+
info: (...args: unknown[]) => void;
|
|
6
|
+
warn: (...args: unknown[]) => void;
|
|
7
|
+
error: (...args: unknown[]) => void;
|
|
8
|
+
};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const LEVELS = {
|
|
2
|
+
debug: 0,
|
|
3
|
+
info: 1,
|
|
4
|
+
warn: 2,
|
|
5
|
+
error: 3,
|
|
6
|
+
};
|
|
7
|
+
let currentLevel = LEVELS.warn;
|
|
8
|
+
export function setupLogger(level) {
|
|
9
|
+
currentLevel = LEVELS[level] ?? LEVELS.warn;
|
|
10
|
+
}
|
|
11
|
+
function log(level, ...args) {
|
|
12
|
+
if (LEVELS[level] >= currentLevel) {
|
|
13
|
+
const prefix = `[happy-mcp] [${level.toUpperCase()}]`;
|
|
14
|
+
console.error(prefix, ...args);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const logger = {
|
|
18
|
+
debug: (...args) => log('debug', ...args),
|
|
19
|
+
info: (...args) => log('info', ...args),
|
|
20
|
+
warn: (...args) => log('warn', ...args),
|
|
21
|
+
error: (...args) => log('error', ...args),
|
|
22
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import type { Credentials } from '../auth/crypto.js';
|
|
3
|
+
import type { SessionManager } from '../session/manager.js';
|
|
4
|
+
import type { Config } from '../config.js';
|
|
5
|
+
export declare class RelayClient extends EventEmitter {
|
|
6
|
+
private socket;
|
|
7
|
+
private credentials;
|
|
8
|
+
private sessionManager;
|
|
9
|
+
private config;
|
|
10
|
+
private _connected;
|
|
11
|
+
constructor(credentials: Credentials, sessionManager: SessionManager, config: Config);
|
|
12
|
+
get connected(): boolean;
|
|
13
|
+
connect(): Promise<void>;
|
|
14
|
+
private waitForConnect;
|
|
15
|
+
private handleUpdate;
|
|
16
|
+
private handleNewMessage;
|
|
17
|
+
private handleUpdateSession;
|
|
18
|
+
private handleUpdateMachine;
|
|
19
|
+
private handleDeleteSession;
|
|
20
|
+
/**
|
|
21
|
+
* Encrypted RPC call to a session.
|
|
22
|
+
* Event: 'rpc-call' (NOT 'rpc-request' -- asymmetric names)
|
|
23
|
+
* Params are encrypted with session key.
|
|
24
|
+
* Response is encrypted with session key.
|
|
25
|
+
*/
|
|
26
|
+
sessionRpc<R>(sessionId: string, method: string, params: unknown): Promise<R>;
|
|
27
|
+
/**
|
|
28
|
+
* Encrypted RPC call to a machine.
|
|
29
|
+
* Used for start_session (spawn-happy-session).
|
|
30
|
+
*/
|
|
31
|
+
machineRpc<R>(machineId: string, method: string, params: unknown): Promise<R>;
|
|
32
|
+
updateToken(newToken: string): void;
|
|
33
|
+
disconnect(): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { io } from 'socket.io-client';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { decodeBase64, decrypt, encrypt, encodeBase64, decryptFromBase64 } from '../auth/crypto.js';
|
|
4
|
+
import { logger } from '../logger.js';
|
|
5
|
+
import { RelayError } from '../errors.js';
|
|
6
|
+
export class RelayClient extends EventEmitter {
|
|
7
|
+
socket = null;
|
|
8
|
+
credentials;
|
|
9
|
+
sessionManager;
|
|
10
|
+
config;
|
|
11
|
+
_connected = false;
|
|
12
|
+
constructor(credentials, sessionManager, config) {
|
|
13
|
+
super();
|
|
14
|
+
this.credentials = credentials;
|
|
15
|
+
this.sessionManager = sessionManager;
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
get connected() {
|
|
19
|
+
return this._connected;
|
|
20
|
+
}
|
|
21
|
+
async connect() {
|
|
22
|
+
this.socket = io(this.config.serverUrl, {
|
|
23
|
+
path: '/v1/updates',
|
|
24
|
+
transports: ['websocket'],
|
|
25
|
+
auth: {
|
|
26
|
+
token: this.credentials.token,
|
|
27
|
+
clientType: 'user-scoped',
|
|
28
|
+
},
|
|
29
|
+
reconnection: true,
|
|
30
|
+
reconnectionAttempts: Infinity,
|
|
31
|
+
reconnectionDelay: 1000,
|
|
32
|
+
reconnectionDelayMax: 5000,
|
|
33
|
+
autoConnect: false,
|
|
34
|
+
});
|
|
35
|
+
this.socket.on('connect', () => {
|
|
36
|
+
this._connected = true;
|
|
37
|
+
logger.info('Relay connected');
|
|
38
|
+
this.emit('connected');
|
|
39
|
+
});
|
|
40
|
+
this.socket.on('disconnect', (reason) => {
|
|
41
|
+
this._connected = false;
|
|
42
|
+
logger.warn('Relay disconnected:', reason);
|
|
43
|
+
// Session cache is PRESERVED -- do NOT clear sessionManager
|
|
44
|
+
this.emit('disconnected', reason);
|
|
45
|
+
});
|
|
46
|
+
this.socket.on('connect_error', (err) => {
|
|
47
|
+
logger.error('Relay connection error:', err.message);
|
|
48
|
+
});
|
|
49
|
+
this.socket.on('update', (data) => {
|
|
50
|
+
try {
|
|
51
|
+
this.handleUpdate(data);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logger.error('Error handling update:', err.message);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
this.socket.connect();
|
|
58
|
+
await this.waitForConnect(10_000);
|
|
59
|
+
}
|
|
60
|
+
async waitForConnect(timeoutMs) {
|
|
61
|
+
if (this._connected)
|
|
62
|
+
return;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const timeout = setTimeout(() => {
|
|
65
|
+
reject(new RelayError(`Relay connection timed out after ${timeoutMs}ms`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
this.socket.once('connect', () => {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
this.socket.once('connect_error', (err) => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
reject(new RelayError(`Relay connection failed: ${err.message}`));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
handleUpdate(data) {
|
|
78
|
+
const container = data;
|
|
79
|
+
const body = container?.body;
|
|
80
|
+
if (!body?.t)
|
|
81
|
+
return;
|
|
82
|
+
switch (body.t) {
|
|
83
|
+
case 'new-message':
|
|
84
|
+
this.handleNewMessage(body);
|
|
85
|
+
break;
|
|
86
|
+
case 'update-session':
|
|
87
|
+
this.handleUpdateSession(body);
|
|
88
|
+
break;
|
|
89
|
+
case 'update-machine':
|
|
90
|
+
this.handleUpdateMachine(body);
|
|
91
|
+
break;
|
|
92
|
+
case 'new-session':
|
|
93
|
+
this.emit('new_session', body);
|
|
94
|
+
break;
|
|
95
|
+
case 'delete-session':
|
|
96
|
+
this.handleDeleteSession(body);
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
logger.debug('Unhandled update type:', body.t);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
handleNewMessage(body) {
|
|
103
|
+
const sessionId = body.sid;
|
|
104
|
+
if (!sessionId)
|
|
105
|
+
return;
|
|
106
|
+
const session = this.sessionManager.get(sessionId);
|
|
107
|
+
if (!session)
|
|
108
|
+
return;
|
|
109
|
+
const message = body.message;
|
|
110
|
+
if (!message?.content || message.content.t !== 'encrypted' || !message.content.c)
|
|
111
|
+
return;
|
|
112
|
+
const decrypted = decrypt(session.encryption.key, session.encryption.variant, decodeBase64(message.content.c));
|
|
113
|
+
if (decrypted === null) {
|
|
114
|
+
logger.warn(`Failed to decrypt message ${message.id} for session ${sessionId}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.sessionManager.applyMessage(sessionId, message.id ?? '', message.seq ?? 0, decrypted, message.createdAt ?? Date.now());
|
|
118
|
+
this.emit('message', sessionId, decrypted);
|
|
119
|
+
}
|
|
120
|
+
handleUpdateSession(body) {
|
|
121
|
+
const sessionId = body.id;
|
|
122
|
+
if (!sessionId)
|
|
123
|
+
return;
|
|
124
|
+
const session = this.sessionManager.get(sessionId);
|
|
125
|
+
if (!session)
|
|
126
|
+
return;
|
|
127
|
+
// Handle versioned metadata
|
|
128
|
+
const metadata = body.metadata;
|
|
129
|
+
if (metadata?.version != null && metadata.version > session.metadataVersion && metadata.value) {
|
|
130
|
+
const decrypted = decryptFromBase64(session.encryption, metadata.value);
|
|
131
|
+
this.sessionManager.updateMetadata(sessionId, decrypted, metadata.version);
|
|
132
|
+
}
|
|
133
|
+
// Handle versioned agentState
|
|
134
|
+
const agentState = body.agentState;
|
|
135
|
+
if (agentState?.version != null && agentState.version > session.agentStateVersion) {
|
|
136
|
+
const decrypted = agentState.value
|
|
137
|
+
? decryptFromBase64(session.encryption, agentState.value)
|
|
138
|
+
: null;
|
|
139
|
+
this.sessionManager.updateAgentState(sessionId, decrypted, agentState.version);
|
|
140
|
+
}
|
|
141
|
+
this.emit('session_update', sessionId);
|
|
142
|
+
}
|
|
143
|
+
handleUpdateMachine(body) {
|
|
144
|
+
const machineId = body.machineId;
|
|
145
|
+
if (!machineId)
|
|
146
|
+
return;
|
|
147
|
+
const machine = this.sessionManager.getMachine(machineId);
|
|
148
|
+
if (!machine)
|
|
149
|
+
return;
|
|
150
|
+
const metadata = body.metadata;
|
|
151
|
+
if (metadata?.version != null && machine.encryption) {
|
|
152
|
+
const decrypted = metadata.value
|
|
153
|
+
? decryptFromBase64(machine.encryption, metadata.value)
|
|
154
|
+
: null;
|
|
155
|
+
this.sessionManager.updateMachineMetadata(machineId, decrypted, metadata.version);
|
|
156
|
+
}
|
|
157
|
+
const daemonState = body.daemonState;
|
|
158
|
+
if (daemonState?.version != null && machine.encryption) {
|
|
159
|
+
const decrypted = daemonState.value
|
|
160
|
+
? decryptFromBase64(machine.encryption, daemonState.value)
|
|
161
|
+
: null;
|
|
162
|
+
this.sessionManager.updateMachineDaemonState(machineId, decrypted, daemonState.version);
|
|
163
|
+
}
|
|
164
|
+
if (body.active !== undefined)
|
|
165
|
+
machine.active = body.active;
|
|
166
|
+
if (body.activeAt !== undefined)
|
|
167
|
+
machine.activeAt = body.activeAt;
|
|
168
|
+
this.emit('machine_update', machineId);
|
|
169
|
+
}
|
|
170
|
+
handleDeleteSession(body) {
|
|
171
|
+
const sessionId = body.id;
|
|
172
|
+
if (sessionId) {
|
|
173
|
+
this.sessionManager.remove(sessionId);
|
|
174
|
+
this.emit('session_deleted', sessionId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Encrypted RPC call to a session.
|
|
179
|
+
* Event: 'rpc-call' (NOT 'rpc-request' -- asymmetric names)
|
|
180
|
+
* Params are encrypted with session key.
|
|
181
|
+
* Response is encrypted with session key.
|
|
182
|
+
*/
|
|
183
|
+
async sessionRpc(sessionId, method, params) {
|
|
184
|
+
if (!this._connected || !this.socket) {
|
|
185
|
+
throw new RelayError('Relay not connected');
|
|
186
|
+
}
|
|
187
|
+
const session = this.sessionManager.get(sessionId);
|
|
188
|
+
if (!session) {
|
|
189
|
+
throw new RelayError(`Session ${sessionId} not found in cache`);
|
|
190
|
+
}
|
|
191
|
+
const encryptedParams = encodeBase64(encrypt(session.encryption.key, session.encryption.variant, params));
|
|
192
|
+
const result = await this.socket.emitWithAck('rpc-call', {
|
|
193
|
+
method: `${sessionId}:${method}`,
|
|
194
|
+
params: encryptedParams,
|
|
195
|
+
});
|
|
196
|
+
if (!result?.ok) {
|
|
197
|
+
throw new RelayError(`RPC ${method} failed for session ${sessionId}: ${result?.error ?? 'unknown error'}`);
|
|
198
|
+
}
|
|
199
|
+
if (result.result) {
|
|
200
|
+
return decrypt(session.encryption.key, session.encryption.variant, decodeBase64(result.result));
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Encrypted RPC call to a machine.
|
|
206
|
+
* Used for start_session (spawn-happy-session).
|
|
207
|
+
*/
|
|
208
|
+
async machineRpc(machineId, method, params) {
|
|
209
|
+
if (!this._connected || !this.socket) {
|
|
210
|
+
throw new RelayError('Relay not connected');
|
|
211
|
+
}
|
|
212
|
+
const machine = this.sessionManager.getMachine(machineId);
|
|
213
|
+
if (!machine?.encryption) {
|
|
214
|
+
throw new RelayError(`Machine ${machineId} not found or no encryption key`);
|
|
215
|
+
}
|
|
216
|
+
const encryptedParams = encodeBase64(encrypt(machine.encryption.key, machine.encryption.variant, params));
|
|
217
|
+
const result = await this.socket.emitWithAck('rpc-call', {
|
|
218
|
+
method: `${machineId}:${method}`,
|
|
219
|
+
params: encryptedParams,
|
|
220
|
+
});
|
|
221
|
+
if (!result?.ok) {
|
|
222
|
+
throw new RelayError(`Machine RPC ${method} failed: ${result?.error ?? 'unknown error'}`);
|
|
223
|
+
}
|
|
224
|
+
if (result.result) {
|
|
225
|
+
return decrypt(machine.encryption.key, machine.encryption.variant, decodeBase64(result.result));
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
updateToken(newToken) {
|
|
230
|
+
if (this.socket) {
|
|
231
|
+
this.socket.auth.token = newToken;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
disconnect() {
|
|
235
|
+
if (this.socket) {
|
|
236
|
+
this.socket.removeAllListeners();
|
|
237
|
+
this.socket.disconnect();
|
|
238
|
+
this.socket = null;
|
|
239
|
+
}
|
|
240
|
+
this._connected = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } 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
|
+
import type { Config } from './config.js';
|
|
6
|
+
export interface UnauthenticatedServer {
|
|
7
|
+
server: McpServer;
|
|
8
|
+
activate: (api: ApiClient, relay: RelayClient, sessionManager: SessionManager) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Create an MCP server in unauthenticated mode.
|
|
12
|
+
* All tools are registered as stubs that call the provided callback when invoked.
|
|
13
|
+
* The callback should attempt authentication and return true on success.
|
|
14
|
+
* Call activate() to swap in real tool handlers.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createUnauthenticatedServer(config: Config, onToolCallWhileUnauthenticated: () => Promise<boolean>): UnauthenticatedServer;
|