happy-mcp-server 0.1.1 → 0.3.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/dist/http.d.ts +14 -0
- package/dist/http.js +180 -0
- package/dist/index.js +92 -7
- package/dist/server.d.ts +5 -0
- package/dist/server.js +21 -14
- package/dist/tools/interrupt_session.d.ts +4 -0
- package/dist/tools/interrupt_session.js +31 -0
- package/dist/tools/start_session.d.ts +2 -1
- package/dist/tools/start_session.js +28 -7
- package/package.json +1 -1
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Config } from './config.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 interface HttpServerHandle {
|
|
6
|
+
port: number;
|
|
7
|
+
close: () => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Start an HTTP server for MCP over HTTP transport.
|
|
11
|
+
* Creates a new McpServer instance per client session.
|
|
12
|
+
* All sessions share the same relay/sessionManager/api singletons.
|
|
13
|
+
*/
|
|
14
|
+
export declare function startHttpServer(config: Config, api: ApiClient, relay: RelayClient, sessionManager: SessionManager, port?: number): Promise<HttpServerHandle>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
5
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { registerAllTools } from './server.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
/**
|
|
9
|
+
* Start an HTTP server for MCP over HTTP transport.
|
|
10
|
+
* Creates a new McpServer instance per client session.
|
|
11
|
+
* All sessions share the same relay/sessionManager/api singletons.
|
|
12
|
+
*/
|
|
13
|
+
export async function startHttpServer(config, api, relay, sessionManager, port = 0) {
|
|
14
|
+
const app = createMcpExpressApp();
|
|
15
|
+
const transports = new Map();
|
|
16
|
+
// POST /mcp - handle initialization and subsequent requests
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
app.post('/mcp', async (req, res) => {
|
|
19
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
20
|
+
if (sessionId) {
|
|
21
|
+
logger.debug(`Received MCP request for session: ${sessionId}`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
let transportInfo;
|
|
25
|
+
if (sessionId && transports.has(sessionId)) {
|
|
26
|
+
// Reuse existing transport for this session
|
|
27
|
+
transportInfo = transports.get(sessionId);
|
|
28
|
+
}
|
|
29
|
+
else if (!sessionId && isInitializeRequest(req.body)) {
|
|
30
|
+
// New initialization request - create new transport + server
|
|
31
|
+
logger.debug('New initialization request received');
|
|
32
|
+
const transport = new StreamableHTTPServerTransport({
|
|
33
|
+
sessionIdGenerator: () => randomUUID(),
|
|
34
|
+
onsessioninitialized: (sid) => {
|
|
35
|
+
logger.debug(`Session initialized with ID: ${sid}`);
|
|
36
|
+
transports.set(sid, transportInfo);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
// Set up cleanup handler
|
|
40
|
+
transport.onclose = () => {
|
|
41
|
+
const sid = transport.sessionId;
|
|
42
|
+
if (sid && transports.has(sid)) {
|
|
43
|
+
logger.debug(`Transport closed for session ${sid}, removing from map`);
|
|
44
|
+
const info = transports.get(sid);
|
|
45
|
+
if (info) {
|
|
46
|
+
info.server.close().catch((err) => {
|
|
47
|
+
logger.debug(`Error closing server for session ${sid}:`, err);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
transports.delete(sid);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
// Create a new McpServer instance for this session
|
|
54
|
+
const server = new McpServer({
|
|
55
|
+
name: 'happy-mcp',
|
|
56
|
+
version: '0.1.0',
|
|
57
|
+
});
|
|
58
|
+
// Register all tools on this server instance
|
|
59
|
+
registerAllTools(server, config, api, relay, sessionManager);
|
|
60
|
+
// Store the transport info (before connect so onsessioninitialized can access it)
|
|
61
|
+
transportInfo = { transport, server };
|
|
62
|
+
// Connect the transport to the server
|
|
63
|
+
await server.connect(transport);
|
|
64
|
+
// Handle the initialization request
|
|
65
|
+
await transport.handleRequest(req, res, req.body);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Invalid request - no session ID or not initialization request
|
|
70
|
+
res.status(400).json({
|
|
71
|
+
jsonrpc: '2.0',
|
|
72
|
+
error: {
|
|
73
|
+
code: -32000,
|
|
74
|
+
message: 'Bad Request: No valid session ID provided',
|
|
75
|
+
},
|
|
76
|
+
id: null,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Handle request with existing transport
|
|
81
|
+
await transportInfo.transport.handleRequest(req, res, req.body);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
logger.debug('Error handling MCP POST request:', error);
|
|
85
|
+
if (!res.headersSent) {
|
|
86
|
+
res.status(500).json({
|
|
87
|
+
jsonrpc: '2.0',
|
|
88
|
+
error: {
|
|
89
|
+
code: -32603,
|
|
90
|
+
message: 'Internal server error',
|
|
91
|
+
},
|
|
92
|
+
id: null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// GET /mcp - SSE stream for notifications
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
app.get('/mcp', async (req, res) => {
|
|
100
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
101
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
102
|
+
res.status(400).send('Invalid or missing session ID');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const lastEventId = req.headers['last-event-id'];
|
|
106
|
+
if (lastEventId) {
|
|
107
|
+
logger.debug(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
logger.debug(`Establishing new SSE stream for session ${sessionId}`);
|
|
111
|
+
}
|
|
112
|
+
const transportInfo = transports.get(sessionId);
|
|
113
|
+
await transportInfo.transport.handleRequest(req, res);
|
|
114
|
+
});
|
|
115
|
+
// DELETE /mcp - session termination
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
app.delete('/mcp', async (req, res) => {
|
|
118
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
119
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
120
|
+
res.status(400).send('Invalid or missing session ID');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
logger.debug(`Received session termination request for session ${sessionId}`);
|
|
124
|
+
try {
|
|
125
|
+
const transportInfo = transports.get(sessionId);
|
|
126
|
+
await transportInfo.transport.handleRequest(req, res);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
logger.debug('Error handling session termination:', error);
|
|
130
|
+
if (!res.headersSent) {
|
|
131
|
+
res.status(500).send('Error processing session termination');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// Start listening on auto-assigned port
|
|
136
|
+
const httpServer = await new Promise((resolve, reject) => {
|
|
137
|
+
const server = app.listen(port, '127.0.0.1', (error) => {
|
|
138
|
+
if (error) {
|
|
139
|
+
reject(error);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
resolve(server);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.on('error', reject);
|
|
146
|
+
});
|
|
147
|
+
const addr = httpServer.address();
|
|
148
|
+
if (!addr || typeof addr === 'string') {
|
|
149
|
+
throw new Error('Failed to get server address');
|
|
150
|
+
}
|
|
151
|
+
const actualPort = addr.port;
|
|
152
|
+
logger.debug(`HTTP server listening on http://127.0.0.1:${actualPort}/mcp`);
|
|
153
|
+
// Cleanup function
|
|
154
|
+
const close = async () => {
|
|
155
|
+
logger.debug('Closing HTTP server...');
|
|
156
|
+
// Close all active transports and servers
|
|
157
|
+
for (const [sessionId, info] of transports.entries()) {
|
|
158
|
+
try {
|
|
159
|
+
logger.debug(`Closing transport for session ${sessionId}`);
|
|
160
|
+
await info.transport.close();
|
|
161
|
+
await info.server.close();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
logger.debug(`Error closing session ${sessionId}:`, error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
transports.clear();
|
|
168
|
+
// Close HTTP server
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
httpServer.close((err) => {
|
|
171
|
+
if (err)
|
|
172
|
+
reject(err);
|
|
173
|
+
else
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
logger.debug('HTTP server closed');
|
|
178
|
+
};
|
|
179
|
+
return { port: actualPort, close };
|
|
180
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { SessionManager } from './session/manager.js';
|
|
|
10
10
|
import { ApiClient } from './api/client.js';
|
|
11
11
|
import { RelayClient } from './relay/client.js';
|
|
12
12
|
import { createUnauthenticatedServer } from './server.js';
|
|
13
|
+
import { startHttpServer } from './http.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Helpers
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
@@ -151,16 +152,44 @@ async function initializeAuthenticatedState(config, credentials) {
|
|
|
151
152
|
// CLI Commands
|
|
152
153
|
// ---------------------------------------------------------------------------
|
|
153
154
|
function printHelp() {
|
|
154
|
-
console.error(`Usage: happy-mcp [command]
|
|
155
|
+
console.error(`Usage: happy-mcp [command] [options]
|
|
155
156
|
|
|
156
157
|
Commands:
|
|
157
|
-
(no args)
|
|
158
|
-
|
|
159
|
-
auth
|
|
160
|
-
auth
|
|
161
|
-
|
|
158
|
+
(no args) Start the MCP server (stdio transport)
|
|
159
|
+
serve [--port <port>] Start the MCP server (HTTP transport)
|
|
160
|
+
auth Check auth status, or pair if not authenticated
|
|
161
|
+
auth login Run the QR pairing flow (re-auth if already paired)
|
|
162
|
+
auth logout Remove saved credentials
|
|
163
|
+
help Show this help message
|
|
164
|
+
|
|
165
|
+
Options for serve:
|
|
166
|
+
--port <port> Bind to a specific port (default: auto-assign)
|
|
162
167
|
`);
|
|
163
168
|
}
|
|
169
|
+
function parseServeFlags(argv) {
|
|
170
|
+
let port;
|
|
171
|
+
for (let i = 0; i < argv.length; i++) {
|
|
172
|
+
if (argv[i] === '--port') {
|
|
173
|
+
const raw = argv[i + 1];
|
|
174
|
+
if (raw === undefined || raw.startsWith('-')) {
|
|
175
|
+
console.error('[happy-mcp] --port requires a port number. Example: happy-mcp serve --port 3000');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const parsed = Number(raw);
|
|
179
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
180
|
+
console.error(`[happy-mcp] Invalid port '${raw}'. Must be an integer between 1 and 65535.`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
port = parsed;
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
console.error(`[happy-mcp] Unknown flag '${argv[i]}'. Run \`happy-mcp help\` for help.`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { port };
|
|
192
|
+
}
|
|
164
193
|
async function runAuth() {
|
|
165
194
|
const config = loadConfig();
|
|
166
195
|
const creds = readCredentials(config.credentialsPath);
|
|
@@ -246,7 +275,7 @@ async function runServer() {
|
|
|
246
275
|
// Connect stdio transport FIRST (Issue 1: prevent blocking)
|
|
247
276
|
const transport = new StdioServerTransport();
|
|
248
277
|
await server.connect(transport);
|
|
249
|
-
logger.info(
|
|
278
|
+
logger.info(`MCP server started — credentials path: ${config.credentialsPath}`);
|
|
250
279
|
// Then initialize auth state via tryActivate() only (consolidate startup path)
|
|
251
280
|
const initialCreds = readCredentials(config.credentialsPath);
|
|
252
281
|
if (initialCreds) {
|
|
@@ -284,6 +313,55 @@ async function runServer() {
|
|
|
284
313
|
});
|
|
285
314
|
}
|
|
286
315
|
// ---------------------------------------------------------------------------
|
|
316
|
+
// HTTP Server
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
async function runHttpServer(port) {
|
|
319
|
+
const config = loadConfig();
|
|
320
|
+
setupLogger(config.logLevel);
|
|
321
|
+
const creds = readCredentials(config.credentialsPath);
|
|
322
|
+
if (!creds) {
|
|
323
|
+
console.error('[happy-mcp] No credentials found. Run `happy-mcp auth` first.');
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
validateFilePermissions(config.credentialsPath);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
console.error('[happy-mcp] Credential file has unsafe permissions:', err.message);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
logger.info('Starting happy-mcp HTTP server...');
|
|
334
|
+
const state = await initializeAuthenticatedState(config, creds);
|
|
335
|
+
let result;
|
|
336
|
+
try {
|
|
337
|
+
result = await startHttpServer(config, state.api, state.relay, state.sessionManager, port ?? 0);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
const err = error;
|
|
341
|
+
if (err.code === 'EADDRINUSE') {
|
|
342
|
+
console.error(`[happy-mcp] Port ${port} is already in use.`);
|
|
343
|
+
}
|
|
344
|
+
else if (err.code === 'EACCES') {
|
|
345
|
+
console.error(`[happy-mcp] Permission denied binding to port ${port}. Try a port above 1023.`);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
console.error(`[happy-mcp] Failed to start HTTP server: ${err.message ?? err}`);
|
|
349
|
+
}
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
console.error(`[happy-mcp] HTTP server listening on http://127.0.0.1:${result.port}/mcp`);
|
|
353
|
+
const shutdown = async () => {
|
|
354
|
+
logger.info('Shutting down HTTP server...');
|
|
355
|
+
await result.close();
|
|
356
|
+
state.relay.disconnect();
|
|
357
|
+
state.sessionManager.destroy();
|
|
358
|
+
clearKeyCache();
|
|
359
|
+
process.exit(0);
|
|
360
|
+
};
|
|
361
|
+
process.on('SIGINT', shutdown);
|
|
362
|
+
process.on('SIGTERM', shutdown);
|
|
363
|
+
}
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
287
365
|
// CLI Router
|
|
288
366
|
// ---------------------------------------------------------------------------
|
|
289
367
|
const command = process.argv[2];
|
|
@@ -293,6 +371,13 @@ if (!command) {
|
|
|
293
371
|
process.exit(1);
|
|
294
372
|
});
|
|
295
373
|
}
|
|
374
|
+
else if (command === 'serve') {
|
|
375
|
+
const flags = parseServeFlags(process.argv.slice(3));
|
|
376
|
+
runHttpServer(flags.port).catch((err) => {
|
|
377
|
+
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
296
381
|
else if (command === 'auth') {
|
|
297
382
|
const subcommand = process.argv[3];
|
|
298
383
|
if (!subcommand) {
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import type { ApiClient } from './api/client.js';
|
|
|
3
3
|
import type { RelayClient } from './relay/client.js';
|
|
4
4
|
import type { SessionManager } from './session/manager.js';
|
|
5
5
|
import type { Config } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* Register all real tool handlers on an McpServer instance.
|
|
8
|
+
* Used by both stdio (via activate()) and HTTP (directly).
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerAllTools(server: McpServer, config: Config, api: ApiClient, relay: RelayClient, sessionManager: SessionManager): void;
|
|
6
11
|
export interface UnauthenticatedServer {
|
|
7
12
|
server: McpServer;
|
|
8
13
|
activate: (api: ApiClient, relay: RelayClient, sessionManager: SessionManager) => void;
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerApprovePermission } from './tools/approve_permission.js';
|
|
|
8
8
|
import { registerDenyPermission } from './tools/deny_permission.js';
|
|
9
9
|
import { registerAnswerQuestion } from './tools/answer_question.js';
|
|
10
10
|
import { registerStartSession } from './tools/start_session.js';
|
|
11
|
+
import { registerInterruptSession } from './tools/interrupt_session.js';
|
|
11
12
|
const AUTH_ERROR = {
|
|
12
13
|
isError: true,
|
|
13
14
|
content: [{
|
|
@@ -29,8 +30,27 @@ const TOOL_STUBS = [
|
|
|
29
30
|
['send_message', 'Send a message to a session.'],
|
|
30
31
|
['approve_permission', 'Approve a pending permission request.'],
|
|
31
32
|
['deny_permission', 'Deny a pending permission request.'],
|
|
33
|
+
['interrupt_session', 'Interrupt a running session to stop its current activity.'],
|
|
32
34
|
['answer_question', 'Answer a question from a session.'],
|
|
33
35
|
];
|
|
36
|
+
/**
|
|
37
|
+
* Register all real tool handlers on an McpServer instance.
|
|
38
|
+
* Used by both stdio (via activate()) and HTTP (directly).
|
|
39
|
+
*/
|
|
40
|
+
export function registerAllTools(server, config, api, relay, sessionManager) {
|
|
41
|
+
registerListComputers(server, sessionManager, config);
|
|
42
|
+
registerListSessions(server, sessionManager, config);
|
|
43
|
+
registerGetSession(server, api, sessionManager);
|
|
44
|
+
registerWatchSession(server, relay, sessionManager);
|
|
45
|
+
registerSendMessage(server, api, sessionManager);
|
|
46
|
+
registerApprovePermission(server, relay, sessionManager);
|
|
47
|
+
registerDenyPermission(server, relay, sessionManager);
|
|
48
|
+
registerInterruptSession(server, relay, sessionManager);
|
|
49
|
+
registerAnswerQuestion(server, api, relay, sessionManager);
|
|
50
|
+
if (config.enableStart) {
|
|
51
|
+
registerStartSession(server, api, relay, sessionManager, config);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
34
54
|
/**
|
|
35
55
|
* Create an MCP server in unauthenticated mode.
|
|
36
56
|
* All tools are registered as stubs that call the provided callback when invoked.
|
|
@@ -59,19 +79,6 @@ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticat
|
|
|
59
79
|
stubRegistrations.push(server.tool('start_session', 'Start a new Happy Coder session.', {}, stubHandler));
|
|
60
80
|
}
|
|
61
81
|
}
|
|
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
82
|
// Start with stubs
|
|
76
83
|
registerStubs();
|
|
77
84
|
return {
|
|
@@ -83,7 +90,7 @@ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticat
|
|
|
83
90
|
}
|
|
84
91
|
stubRegistrations = [];
|
|
85
92
|
// Register real tools
|
|
86
|
-
|
|
93
|
+
registerAllTools(server, config, api, relay, sessionManager);
|
|
87
94
|
},
|
|
88
95
|
};
|
|
89
96
|
}
|
|
@@ -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 registerInterruptSession(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 registerInterruptSession(server, relay, sessionManager) {
|
|
4
|
+
return server.tool('interrupt_session', 'Interrupt a running Claude Code session to stop its current activity. This sends an abort signal equivalent to pressing Escape — the session stays alive and can accept new messages afterward. Use this when a session is actively generating output and you need it to stop.', {
|
|
5
|
+
sessionId: z.string().describe('The session ID to interrupt'),
|
|
6
|
+
}, async ({ sessionId }) => {
|
|
7
|
+
try {
|
|
8
|
+
if (!relay.connected) {
|
|
9
|
+
const msg = relay.state === 'connecting'
|
|
10
|
+
? 'Relay is still connecting. Please try again in a few seconds.'
|
|
11
|
+
: 'Relay is disconnected.';
|
|
12
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
13
|
+
error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
|
|
14
|
+
message: msg,
|
|
15
|
+
}) }] };
|
|
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
|
+
await relay.sessionRpc(sessionId, 'abort', {
|
|
22
|
+
reason: "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.",
|
|
23
|
+
});
|
|
24
|
+
logger.info(`[audit] Session INTERRUPTED: session=${sessionId}`);
|
|
25
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, sessionId, message: 'Interrupt signal sent. The session will stop its current activity.' }, null, 2) }] };
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'InterruptFailed', message: err.message }) }] };
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -2,4 +2,5 @@ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server
|
|
|
2
2
|
import type { ApiClient } from '../api/client.js';
|
|
3
3
|
import type { RelayClient } from '../relay/client.js';
|
|
4
4
|
import type { SessionManager } from '../session/manager.js';
|
|
5
|
-
|
|
5
|
+
import type { Config } from '../config.js';
|
|
6
|
+
export declare function registerStartSession(server: McpServer, _api: ApiClient, relay: RelayClient, sessionManager: SessionManager, config: Config): RegisteredTool;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'];
|
|
3
|
-
export function registerStartSession(server, _api, relay, sessionManager) {
|
|
3
|
+
export function registerStartSession(server, _api, relay, sessionManager, config) {
|
|
4
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
5
|
computer: z.string().describe('The machine ID (from list_computers)'),
|
|
6
|
-
projectPath: z.string().describe('
|
|
6
|
+
projectPath: z.string().describe(config.projectPaths.includes('*')
|
|
7
|
+
? 'The working directory for the new session'
|
|
8
|
+
: `The working directory for the new session. Allowed paths: ${config.projectPaths.join(', ')}`),
|
|
7
9
|
initialMessage: z.string().optional().describe('Initial message to send after session starts'),
|
|
8
10
|
permissionMode: z.enum(PERMISSION_MODES).optional().describe('Permission mode for the session'),
|
|
9
11
|
agent: z.enum(['claude', 'codex', 'gemini']).optional().default('claude').describe('The AI agent to use'),
|
|
@@ -18,12 +20,31 @@ export function registerStartSession(server, _api, relay, sessionManager) {
|
|
|
18
20
|
message: msg,
|
|
19
21
|
}) }] };
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Build the list of allowed online computers (same filter as list_computers)
|
|
24
|
+
const allowedOnline = sessionManager.getAllMachines().filter(m => {
|
|
25
|
+
if (!m.active)
|
|
26
|
+
return false;
|
|
27
|
+
const host = (m.metadata?.host ?? '').toLowerCase();
|
|
28
|
+
return config.computers.some(c => c === '*' || c.toLowerCase() === host);
|
|
29
|
+
});
|
|
30
|
+
const match = allowedOnline.find(m => m.machineId === computer);
|
|
31
|
+
if (!match) {
|
|
32
|
+
const available = allowedOnline.map(m => ({
|
|
33
|
+
machineId: m.machineId,
|
|
34
|
+
hostname: m.metadata?.host ?? 'unknown',
|
|
35
|
+
}));
|
|
36
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
37
|
+
error: 'ComputerNotAvailable',
|
|
38
|
+
message: `Computer ${computer} is not available. Use list_computers to see available machines.`,
|
|
39
|
+
availableComputers: available,
|
|
40
|
+
}) }] };
|
|
24
41
|
}
|
|
25
|
-
if (!
|
|
26
|
-
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
42
|
+
if (!config.projectPaths.includes('*') && !config.projectPaths.includes(projectPath)) {
|
|
43
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
44
|
+
error: 'PathNotAllowed',
|
|
45
|
+
message: `Project path ${projectPath} is not in the allowed project paths.`,
|
|
46
|
+
allowedPaths: config.projectPaths,
|
|
47
|
+
}) }] };
|
|
27
48
|
}
|
|
28
49
|
const result = await relay.machineRpc(computer, 'spawn-happy-session', {
|
|
29
50
|
type: 'spawn-in-directory',
|