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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jared Spencer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # happy-mcp-server
2
+
3
+ An MCP (Model Context Protocol) server that lets AI assistants observe and control active Happy Coder sessions. It connects to the Happy relay server via end-to-end encrypted channels, enabling remote session monitoring, message sending, permission management, and session control.
4
+
5
+ ## Quick Start
6
+
7
+ ### 1. Install
8
+
9
+ ```bash
10
+ npm i -g happy-mcp-server
11
+ ```
12
+
13
+ ### 2. Authenticate
14
+
15
+ ```bash
16
+ happy-mcp auth
17
+ ```
18
+
19
+ Scan the QR code with the Happy mobile app to pair your account.
20
+
21
+ ### 3. Configure Your MCP Client
22
+
23
+ Add `happy-mcp` to your MCP client configuration. See [MCP Configuration](#mcp-configuration) below.
24
+
25
+ ## Configuration
26
+
27
+ Environment variables customize server behavior:
28
+
29
+ | Variable | Default | Description |
30
+ |----------|---------|-------------|
31
+ | `HAPPY_SERVER_URL` | `https://api.cluster-fluster.com` | Happy relay server URL. |
32
+ | `HAPPY_MCP_COMPUTERS` | `os.hostname()` | Comma-separated list of computer hostnames to filter sessions and machines. Use `*` to show all computers. |
33
+ | `HAPPY_MCP_PROJECT_PATHS` | `process.cwd()` | Comma-separated list of project path prefixes to filter sessions. Use `*` to show all paths. |
34
+ | `HAPPY_MCP_LOG_LEVEL` | `warn` | Log level: `debug`, `info`, `warn`, or `error`. Logs are written to stderr. |
35
+ | `HAPPY_MCP_ENABLE_START` | `true` | Set to `false` to disable the `start_session` tool. |
36
+
37
+ ## MCP Configuration
38
+
39
+ <details>
40
+ <summary><b>Claude Code</b></summary>
41
+
42
+ Add to `.mcp.json` in your project root or configure via CLI:
43
+
44
+ ```bash
45
+ claude mcp add --transport stdio happy -- happy-mcp
46
+ ```
47
+
48
+ Or manually add to `.mcp.json`:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "happy": {
54
+ "type": "stdio",
55
+ "command": "happy-mcp"
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ **With custom environment variables:**
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "happy": {
67
+ "type": "stdio",
68
+ "command": "happy-mcp",
69
+ "env": {
70
+ "HAPPY_MCP_COMPUTERS": "*",
71
+ "HAPPY_MCP_PROJECT_PATHS": "*"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ </details>
79
+
80
+ <details>
81
+ <summary><b>Claude Desktop</b></summary>
82
+
83
+ Add to your Claude Desktop config:
84
+
85
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
86
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "happy": {
92
+ "type": "stdio",
93
+ "command": "happy-mcp"
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ </details>
100
+
101
+ <details>
102
+ <summary><b>Cursor</b></summary>
103
+
104
+ Add to `.cursor/mcp.json` in your project or global config:
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "happy": {
110
+ "command": "happy-mcp"
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ </details>
117
+
118
+ <details>
119
+ <summary><b>Windsurf</b></summary>
120
+
121
+ Add to `~/.codeium/windsurf/mcp_config.json`:
122
+
123
+ ```json
124
+ {
125
+ "mcpServers": {
126
+ "happy": {
127
+ "command": "happy-mcp"
128
+ }
129
+ }
130
+ }
131
+ ```
132
+
133
+ </details>
134
+
135
+ <details>
136
+ <summary><b>VS Code with Continue</b></summary>
137
+
138
+ Add to `~/.continue/config.json`:
139
+
140
+ ```json
141
+ {
142
+ "experimental": {
143
+ "modelContextProtocolServers": [
144
+ {
145
+ "transport": {
146
+ "type": "stdio",
147
+ "command": "happy-mcp"
148
+ }
149
+ }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ </details>
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,39 @@
1
+ import type { Credentials } from '../auth/crypto.js';
2
+ import type { Config } from '../config.js';
3
+ import type { RawSession, RawMachine } from '../session/types.js';
4
+ export declare class ApiClient {
5
+ private http;
6
+ private credentials;
7
+ private config;
8
+ constructor(credentials: Credentials, config: Config);
9
+ get token(): string;
10
+ listActiveSessions(): Promise<RawSession[]>;
11
+ listMachines(): Promise<RawMachine[]>;
12
+ getSessionMessages(sessionId: string, afterSeq?: number, limit?: number): Promise<{
13
+ messages: Array<{
14
+ id: string;
15
+ seq: number;
16
+ content: {
17
+ t: string;
18
+ c: string;
19
+ };
20
+ localId?: string;
21
+ createdAt: number;
22
+ updatedAt: number;
23
+ }>;
24
+ hasMore: boolean;
25
+ }>;
26
+ sendMessages(sessionId: string, messages: Array<{
27
+ content: string;
28
+ localId: string;
29
+ }>): Promise<{
30
+ messages: Array<{
31
+ id: string;
32
+ seq: number;
33
+ localId: string;
34
+ createdAt: number;
35
+ updatedAt: number;
36
+ }>;
37
+ }>;
38
+ deleteSession(sessionId: string): Promise<void>;
39
+ }
@@ -0,0 +1,49 @@
1
+ import axios from 'axios';
2
+ import { refreshToken } from '../auth/refresh.js';
3
+ export class ApiClient {
4
+ http;
5
+ credentials;
6
+ config;
7
+ constructor(credentials, config) {
8
+ this.credentials = credentials;
9
+ this.config = config;
10
+ this.http = axios.create({
11
+ baseURL: config.serverUrl,
12
+ headers: { Authorization: `Bearer ${credentials.token}` },
13
+ timeout: 30_000,
14
+ });
15
+ // 401 interceptor for automatic token refresh
16
+ this.http.interceptors.response.use(undefined, async (error) => {
17
+ if (error.response?.status === 401 && !error.config._retried) {
18
+ error.config._retried = true;
19
+ const newToken = await refreshToken(this.credentials, this.config);
20
+ this.http.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
21
+ error.config.headers['Authorization'] = `Bearer ${newToken}`;
22
+ return this.http.request(error.config);
23
+ }
24
+ throw error;
25
+ });
26
+ }
27
+ get token() {
28
+ return this.credentials.token;
29
+ }
30
+ async listActiveSessions() {
31
+ const res = await this.http.get('/v2/sessions/active');
32
+ return res.data.sessions ?? res.data ?? [];
33
+ }
34
+ async listMachines() {
35
+ const res = await this.http.get('/v1/machines');
36
+ return res.data ?? [];
37
+ }
38
+ async getSessionMessages(sessionId, afterSeq = 0, limit = 100) {
39
+ const res = await this.http.get(`/v3/sessions/${encodeURIComponent(sessionId)}/messages`, { params: { after_seq: afterSeq, limit } });
40
+ return res.data;
41
+ }
42
+ async sendMessages(sessionId, messages) {
43
+ const res = await this.http.post(`/v3/sessions/${encodeURIComponent(sessionId)}/messages`, { messages }, { timeout: 60_000 });
44
+ return res.data;
45
+ }
46
+ async deleteSession(sessionId) {
47
+ await this.http.delete(`/v1/sessions/${encodeURIComponent(sessionId)}`);
48
+ }
49
+ }
@@ -0,0 +1,22 @@
1
+ import type { Credentials } from './crypto.js';
2
+ export type { Credentials } from './crypto.js';
3
+ /**
4
+ * Read credentials from disk.
5
+ * Returns null if file doesn't exist or is invalid.
6
+ * Derives contentKeyPair from secret on load.
7
+ */
8
+ export declare function readCredentials(path: string): Credentials | null;
9
+ /**
10
+ * Write credentials to disk with 0600 permissions.
11
+ * Creates parent directory with 0700 if needed.
12
+ */
13
+ export declare function writeCredentials(path: string, token: string, secret: Uint8Array, serverUrl?: string): void;
14
+ /**
15
+ * Validate that credentials file has safe permissions (0600).
16
+ * Throws if group or world readable.
17
+ */
18
+ export declare function validateFilePermissions(path: string): void;
19
+ /**
20
+ * Remove credentials file.
21
+ */
22
+ export declare function clearCredentials(path: string): void;
@@ -0,0 +1,80 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync } from 'fs';
2
+ import { dirname } from 'path';
3
+ import { encodeBase64, decodeBase64, deriveContentKeyPair } from './crypto.js';
4
+ import { logger } from '../logger.js';
5
+ /**
6
+ * Read credentials from disk.
7
+ * Returns null if file doesn't exist or is invalid.
8
+ * Derives contentKeyPair from secret on load.
9
+ */
10
+ export function readCredentials(path) {
11
+ try {
12
+ const raw = readFileSync(path, 'utf-8');
13
+ const parsed = JSON.parse(raw);
14
+ if (!parsed.token || !parsed.secret) {
15
+ logger.warn('Credentials file missing token or secret');
16
+ return null;
17
+ }
18
+ const secret = decodeBase64(parsed.secret);
19
+ if (secret.length !== 32) {
20
+ logger.warn('Credentials secret is not 32 bytes');
21
+ return null;
22
+ }
23
+ const contentKeyPair = deriveContentKeyPair(secret);
24
+ return { token: parsed.token, secret, contentKeyPair };
25
+ }
26
+ catch (err) {
27
+ if (err.code === 'ENOENT') {
28
+ return null; // File doesn't exist, first run
29
+ }
30
+ logger.warn('Failed to read credentials:', err.message);
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Write credentials to disk with 0600 permissions.
36
+ * Creates parent directory with 0700 if needed.
37
+ */
38
+ export function writeCredentials(path, token, secret, serverUrl) {
39
+ const dir = dirname(path);
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ const data = JSON.stringify({
42
+ token,
43
+ secret: encodeBase64(secret),
44
+ ...(serverUrl ? { serverUrl } : {}),
45
+ pairedAt: new Date().toISOString(),
46
+ }, null, 2);
47
+ writeFileSync(path, data, { mode: 0o600 });
48
+ logger.info('Credentials written to', path);
49
+ }
50
+ /**
51
+ * Validate that credentials file has safe permissions (0600).
52
+ * Throws if group or world readable.
53
+ */
54
+ export function validateFilePermissions(path) {
55
+ try {
56
+ const stat = statSync(path);
57
+ const mode = stat.mode & 0o777;
58
+ if (mode & 0o077) {
59
+ throw new Error(`Credentials file ${path} has unsafe permissions ${mode.toString(8)}. ` +
60
+ `Expected 0600. Fix with: chmod 600 ${path}`);
61
+ }
62
+ }
63
+ catch (err) {
64
+ if (err.code === 'ENOENT')
65
+ return;
66
+ throw err;
67
+ }
68
+ }
69
+ /**
70
+ * Remove credentials file.
71
+ */
72
+ export function clearCredentials(path) {
73
+ try {
74
+ unlinkSync(path);
75
+ logger.info('Credentials cleared');
76
+ }
77
+ catch {
78
+ // ignore if doesn't exist
79
+ }
80
+ }
@@ -0,0 +1,118 @@
1
+ import nacl from 'tweetnacl';
2
+ export declare function encodeBase64(buf: Uint8Array): string;
3
+ export declare function decodeBase64(base64: string): Uint8Array;
4
+ export declare function encodeBase64Url(buf: Uint8Array): string;
5
+ export declare function decodeBase64Url(base64url: string): Uint8Array;
6
+ export declare function hmacSha512(key: Uint8Array, data: Uint8Array): Uint8Array;
7
+ interface KeyTreeState {
8
+ key: Uint8Array;
9
+ chainCode: Uint8Array;
10
+ }
11
+ /**
12
+ * Derive root of key tree.
13
+ * CRITICAL: key = encode(usage + ' Master Seed'), data = seed
14
+ * Verified against upstream: packages/happy-agent/src/encryption.ts
15
+ */
16
+ export declare function deriveSecretKeyTreeRoot(seed: Uint8Array, usage: string): KeyTreeState;
17
+ /**
18
+ * Derive child key from chain code.
19
+ * data = [0x00, ...encode(index)]
20
+ */
21
+ export declare function deriveSecretKeyTreeChild(chainCode: Uint8Array, index: string): KeyTreeState;
22
+ /**
23
+ * Derive key at path. Root + iterate children.
24
+ * Test vectors:
25
+ * deriveKey(encode("test seed"), "test usage", [])
26
+ * => E6E55652456F9FE47D6FF46CA3614E85B499F77E7B340FBBB1553307CEDC1E74
27
+ * deriveKey(encode("test seed"), "test usage", ["child1", "child2"])
28
+ * => 1011C097D2105D27362B987A631496BBF68B836124D1D072E9D1613C6028CF75
29
+ */
30
+ export declare function deriveKey(seed: Uint8Array, usage: string, path: string[]): Uint8Array;
31
+ /**
32
+ * CRITICAL: Has a SHA-512 step to match libsodium's crypto_box_seed_keypair behavior.
33
+ * 1. Derive seed via HMAC tree
34
+ * 2. SHA-512(seed)[0:32] = box secret key
35
+ * 3. nacl.box.keyPair.fromSecretKey(boxSecretKey)
36
+ *
37
+ * Source: packages/happy-agent/src/encryption.ts:deriveContentKeyPair
38
+ */
39
+ export declare function deriveContentKeyPair(secret: Uint8Array): nacl.BoxKeyPair;
40
+ /**
41
+ * Generate auth challenge for token refresh.
42
+ * CRITICAL: Uses nacl.sign.keyPair.fromSeed(secret) with the RAW account secret.
43
+ * NOT the derived content keypair (which is Curve25519, not Ed25519).
44
+ *
45
+ * Source: packages/happy-agent/src/encryption.ts:authChallenge
46
+ */
47
+ export declare function authChallenge(secret: Uint8Array): {
48
+ challenge: Uint8Array;
49
+ publicKey: Uint8Array;
50
+ signature: Uint8Array;
51
+ };
52
+ /**
53
+ * Decrypt a NaCl box bundle: [ephemeralPubKey(32)][nonce(24)][ciphertext]
54
+ * Returns decrypted Uint8Array or null on failure.
55
+ *
56
+ * Source: packages/happy-agent/src/encryption.ts
57
+ */
58
+ export declare function decryptBoxBundle(bundle: Uint8Array, recipientSecretKey: Uint8Array): Uint8Array | null;
59
+ /**
60
+ * Encrypt data for a public key using NaCl box.
61
+ * Returns: [ephemeralPubKey(32)][nonce(24)][ciphertext]
62
+ */
63
+ export declare function encryptForPublicKey(data: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array;
64
+ /**
65
+ * Encrypt with AES-256-GCM.
66
+ * Bundle format: [version(1)=0x00][nonce(12)][ciphertext(N)][authTag(16)]
67
+ */
68
+ export declare function encryptAesGcm(key: Uint8Array, data: unknown): Uint8Array;
69
+ /**
70
+ * Decrypt AES-256-GCM bundle.
71
+ * Returns parsed JSON or null on failure.
72
+ */
73
+ export declare function decryptAesGcm(key: Uint8Array, bundle: Uint8Array): unknown | null;
74
+ /**
75
+ * Encrypt with NaCl SecretBox.
76
+ * Bundle format: [nonce(24)][ciphertext]
77
+ */
78
+ export declare function encryptSecretBox(key: Uint8Array, data: unknown): Uint8Array;
79
+ /**
80
+ * Decrypt NaCl SecretBox bundle.
81
+ * Returns parsed JSON or null on failure.
82
+ */
83
+ export declare function decryptSecretBox(key: Uint8Array, bundle: Uint8Array): unknown | null;
84
+ export type EncryptionVariant = 'dataKey' | 'legacy';
85
+ export interface SessionEncryption {
86
+ key: Uint8Array;
87
+ variant: EncryptionVariant;
88
+ }
89
+ /**
90
+ * Encrypt data using the appropriate variant.
91
+ */
92
+ export declare function encrypt(key: Uint8Array, variant: EncryptionVariant, data: unknown): Uint8Array;
93
+ /**
94
+ * Decrypt data using the appropriate variant.
95
+ */
96
+ export declare function decrypt(key: Uint8Array, variant: EncryptionVariant, bundle: Uint8Array): unknown | null;
97
+ /**
98
+ * Encrypt data and return base64 string.
99
+ */
100
+ export declare function encryptToBase64(encryption: SessionEncryption, data: unknown): string;
101
+ /**
102
+ * Decrypt base64-encoded data.
103
+ */
104
+ export declare function decryptFromBase64(encryption: SessionEncryption, base64: string): unknown | null;
105
+ export interface Credentials {
106
+ token: string;
107
+ secret: Uint8Array;
108
+ contentKeyPair: nacl.BoxKeyPair;
109
+ }
110
+ /**
111
+ * Resolve session encryption key.
112
+ * CRITICAL: Strip version byte (first byte) before NaCl box decryption.
113
+ * Pass contentKeyPair.secretKey, NOT the full keypair.
114
+ *
115
+ * Source: packages/happy-agent/src/api.ts:resolveSessionEncryption
116
+ */
117
+ export declare function resolveSessionEncryption(dataEncryptionKey: string | null | undefined, credentials: Credentials): SessionEncryption;
118
+ export {};