happy-mcp-server 0.1.1 → 0.2.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 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): Promise<HttpServerHandle>;
package/dist/http.js ADDED
@@ -0,0 +1,179 @@
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) {
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(0, '127.0.0.1', (error) => {
138
+ if (error) {
139
+ reject(error);
140
+ }
141
+ else {
142
+ resolve(server);
143
+ }
144
+ });
145
+ });
146
+ const addr = httpServer.address();
147
+ if (!addr || typeof addr === 'string') {
148
+ throw new Error('Failed to get server address');
149
+ }
150
+ const port = addr.port;
151
+ logger.debug(`HTTP server listening on http://127.0.0.1:${port}/mcp`);
152
+ // Cleanup function
153
+ const close = async () => {
154
+ logger.debug('Closing HTTP server...');
155
+ // Close all active transports and servers
156
+ for (const [sessionId, info] of transports.entries()) {
157
+ try {
158
+ logger.debug(`Closing transport for session ${sessionId}`);
159
+ await info.transport.close();
160
+ await info.server.close();
161
+ }
162
+ catch (error) {
163
+ logger.debug(`Error closing session ${sessionId}:`, error);
164
+ }
165
+ }
166
+ transports.clear();
167
+ // Close HTTP server
168
+ await new Promise((resolve, reject) => {
169
+ httpServer.close((err) => {
170
+ if (err)
171
+ reject(err);
172
+ else
173
+ resolve();
174
+ });
175
+ });
176
+ logger.debug('HTTP server closed');
177
+ };
178
+ return { port, close };
179
+ }
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
  // ---------------------------------------------------------------------------
@@ -155,6 +156,7 @@ function printHelp() {
155
156
 
156
157
  Commands:
157
158
  (no args) Start the MCP server (stdio transport)
159
+ serve Start the MCP server (HTTP transport, auto-picks port)
158
160
  auth Check auth status, or pair if not authenticated
159
161
  auth login Run the QR pairing flow (re-auth if already paired)
160
162
  auth logout Remove saved credentials
@@ -246,7 +248,7 @@ async function runServer() {
246
248
  // Connect stdio transport FIRST (Issue 1: prevent blocking)
247
249
  const transport = new StdioServerTransport();
248
250
  await server.connect(transport);
249
- logger.info('MCP server stdio transport connected');
251
+ logger.info(`MCP server started credentials path: ${config.credentialsPath}`);
250
252
  // Then initialize auth state via tryActivate() only (consolidate startup path)
251
253
  const initialCreds = readCredentials(config.credentialsPath);
252
254
  if (initialCreds) {
@@ -284,6 +286,39 @@ async function runServer() {
284
286
  });
285
287
  }
286
288
  // ---------------------------------------------------------------------------
289
+ // HTTP Server
290
+ // ---------------------------------------------------------------------------
291
+ async function runHttpServer() {
292
+ const config = loadConfig();
293
+ setupLogger(config.logLevel);
294
+ const creds = readCredentials(config.credentialsPath);
295
+ if (!creds) {
296
+ console.error('[happy-mcp] No credentials found. Run `happy-mcp auth` first.');
297
+ process.exit(1);
298
+ }
299
+ try {
300
+ validateFilePermissions(config.credentialsPath);
301
+ }
302
+ catch (err) {
303
+ console.error('[happy-mcp] Credential file has unsafe permissions:', err.message);
304
+ process.exit(1);
305
+ }
306
+ logger.info('Starting happy-mcp HTTP server...');
307
+ const state = await initializeAuthenticatedState(config, creds);
308
+ const { port, close } = await startHttpServer(config, state.api, state.relay, state.sessionManager);
309
+ console.error(`[happy-mcp] HTTP server listening on http://127.0.0.1:${port}/mcp`);
310
+ const shutdown = async () => {
311
+ logger.info('Shutting down HTTP server...');
312
+ await close();
313
+ state.relay.disconnect();
314
+ state.sessionManager.destroy();
315
+ clearKeyCache();
316
+ process.exit(0);
317
+ };
318
+ process.on('SIGINT', shutdown);
319
+ process.on('SIGTERM', shutdown);
320
+ }
321
+ // ---------------------------------------------------------------------------
287
322
  // CLI Router
288
323
  // ---------------------------------------------------------------------------
289
324
  const command = process.argv[2];
@@ -293,6 +328,12 @@ if (!command) {
293
328
  process.exit(1);
294
329
  });
295
330
  }
331
+ else if (command === 'serve') {
332
+ runHttpServer().catch((err) => {
333
+ console.error('[happy-mcp] Fatal:', err.message ?? err);
334
+ process.exit(1);
335
+ });
336
+ }
296
337
  else if (command === 'auth') {
297
338
  const subcommand = process.argv[3];
298
339
  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
@@ -31,6 +31,23 @@ const TOOL_STUBS = [
31
31
  ['deny_permission', 'Deny a pending permission request.'],
32
32
  ['answer_question', 'Answer a question from a session.'],
33
33
  ];
34
+ /**
35
+ * Register all real tool handlers on an McpServer instance.
36
+ * Used by both stdio (via activate()) and HTTP (directly).
37
+ */
38
+ export function registerAllTools(server, config, api, relay, sessionManager) {
39
+ registerListComputers(server, sessionManager, config);
40
+ registerListSessions(server, sessionManager, config);
41
+ registerGetSession(server, api, sessionManager);
42
+ registerWatchSession(server, relay, sessionManager);
43
+ registerSendMessage(server, api, sessionManager);
44
+ registerApprovePermission(server, relay, sessionManager);
45
+ registerDenyPermission(server, relay, sessionManager);
46
+ registerAnswerQuestion(server, api, relay, sessionManager);
47
+ if (config.enableStart) {
48
+ registerStartSession(server, api, relay, sessionManager, config);
49
+ }
50
+ }
34
51
  /**
35
52
  * Create an MCP server in unauthenticated mode.
36
53
  * All tools are registered as stubs that call the provided callback when invoked.
@@ -59,19 +76,6 @@ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticat
59
76
  stubRegistrations.push(server.tool('start_session', 'Start a new Happy Coder session.', {}, stubHandler));
60
77
  }
61
78
  }
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
79
  // Start with stubs
76
80
  registerStubs();
77
81
  return {
@@ -83,7 +87,7 @@ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticat
83
87
  }
84
88
  stubRegistrations = [];
85
89
  // Register real tools
86
- registerReal(api, relay, sessionManager);
90
+ registerAllTools(server, config, api, relay, sessionManager);
87
91
  },
88
92
  };
89
93
  }
@@ -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
- export declare function registerStartSession(server: McpServer, _api: ApiClient, relay: RelayClient, sessionManager: SessionManager): RegisteredTool;
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('The working directory for the new session'),
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
- const machine = sessionManager.getMachine(computer);
22
- if (!machine) {
23
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MachineNotFound', message: `Machine ${computer} not found` }) }] };
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 (!machine.active) {
26
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MachineOffline', message: `Machine ${computer} is not online` }) }] };
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happy-mcp-server",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for observing and controlling Happy Coder sessions",
5
5
  "author": {
6
6
  "name": "Jared Spencer",