happy-mcp-server 1.1.0 → 1.1.1
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/README.md +45 -26
- package/dist/auth/pairing-manager.d.ts +41 -0
- package/dist/auth/pairing-manager.js +213 -0
- package/dist/auth/pairing.js +2 -2
- package/dist/auth/secret-auth.d.ts +12 -0
- package/dist/auth/secret-auth.js +40 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +18 -1
- package/dist/http.js +1 -1
- package/dist/index.js +120 -58
- package/dist/logger.js +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +53 -6
- package/dist/tools/answer_question.js +1 -1
- package/dist/tools/send_message.js +1 -1
- package/dist/tools/watch_session.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,50 +12,66 @@ npm i -g happy-mcp-server
|
|
|
12
12
|
|
|
13
13
|
### 2. Authenticate
|
|
14
14
|
|
|
15
|
+
**Option A: MCP-based authentication (Recommended for Claude Cowork)**
|
|
16
|
+
|
|
17
|
+
Add the server to your MCP client configuration (see [MCP Configuration](#mcp-configuration) below), then call the `authentication_status` tool from within your MCP client. The tool returns a QR code and web URL for pairing. Once you scan the QR code with the Happy mobile app, tools activate automatically.
|
|
18
|
+
|
|
19
|
+
**Option B: CLI authentication**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
happy-mcp-server auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Scan the QR code with the Happy mobile app to pair your account. This creates credentials at `~/.happy-mcp/credentials.json`.
|
|
26
|
+
|
|
27
|
+
**Option C: Environment variable authentication**
|
|
28
|
+
|
|
29
|
+
For ephemeral environments (Claude Cowork, Docker, CI/CD), set the `HAPPY_MCP_ACCOUNT_SECRET` environment variable:
|
|
30
|
+
|
|
15
31
|
```bash
|
|
16
|
-
happy-mcp
|
|
32
|
+
export HAPPY_MCP_ACCOUNT_SECRET=$(cat ~/.happy-mcp/credentials.json | jq -r .secret)
|
|
17
33
|
```
|
|
18
34
|
|
|
19
|
-
|
|
35
|
+
When set, authentication happens instantly on startup with no QR code or credential file required.
|
|
20
36
|
|
|
21
37
|
### 3. Choose Your Transport
|
|
22
38
|
|
|
23
39
|
**Option A: Stdio Transport (Default)**
|
|
24
40
|
|
|
25
|
-
Configure `happy-mcp` in your MCP client. See [MCP Configuration](#mcp-configuration) below.
|
|
41
|
+
Configure `happy-mcp-server` in your MCP client. See [MCP Configuration](#mcp-configuration) below.
|
|
26
42
|
|
|
27
43
|
**Option B: HTTP Transport**
|
|
28
44
|
|
|
29
45
|
Start the server with HTTP transport:
|
|
30
46
|
|
|
31
47
|
```bash
|
|
32
|
-
happy-mcp serve
|
|
48
|
+
happy-mcp-server serve
|
|
33
49
|
# Or specify a port:
|
|
34
|
-
happy-mcp serve --port 3000
|
|
50
|
+
happy-mcp-server serve --port 3000
|
|
35
51
|
```
|
|
36
52
|
|
|
37
53
|
The server will start at `http://127.0.0.1:<port>/mcp` (port auto-assigned if not specified).
|
|
38
54
|
|
|
39
55
|
## Commands
|
|
40
56
|
|
|
41
|
-
`happy-mcp` provides several commands for different modes and authentication:
|
|
57
|
+
`happy-mcp-server` provides several commands for different modes and authentication:
|
|
42
58
|
|
|
43
59
|
| Command | Description |
|
|
44
60
|
|---------|-------------|
|
|
45
|
-
| `happy-mcp` | Start the MCP server using stdio transport (default). Use this mode when configuring the server in MCP clients like Claude Desktop, Claude Code, Cursor, etc. |
|
|
46
|
-
| `happy-mcp serve` | Start the MCP server using HTTP transport on an auto-assigned port. The server binds to `127.0.0.1` and reports the listening port and endpoint URL on startup. |
|
|
47
|
-
| `happy-mcp serve --port <port>` | Start the HTTP server on a specific port (1-65535). Useful when you need a predictable port number for client configuration or firewall rules. |
|
|
48
|
-
| `happy-mcp auth` | Check authentication status. If not authenticated, prompts you to scan a QR code with the Happy mobile app to pair your account. |
|
|
49
|
-
| `happy-mcp auth login` | Force a new pairing flow, even if already authenticated. Use this to switch accounts or re-authenticate. |
|
|
50
|
-
| `happy-mcp auth logout` | Remove saved credentials from `~/.happy-mcp/credentials.json`. |
|
|
51
|
-
| `happy-mcp help` | Display help message with available commands. |
|
|
61
|
+
| `happy-mcp-server` | Start the MCP server using stdio transport (default). Use this mode when configuring the server in MCP clients like Claude Desktop, Claude Code, Cursor, etc. |
|
|
62
|
+
| `happy-mcp-server serve` | Start the MCP server using HTTP transport on an auto-assigned port. The server binds to `127.0.0.1` and reports the listening port and endpoint URL on startup. |
|
|
63
|
+
| `happy-mcp-server serve --port <port>` | Start the HTTP server on a specific port (1-65535). Useful when you need a predictable port number for client configuration or firewall rules. |
|
|
64
|
+
| `happy-mcp-server auth` | Check authentication status. If not authenticated, prompts you to scan a QR code with the Happy mobile app to pair your account. |
|
|
65
|
+
| `happy-mcp-server auth login` | Force a new pairing flow, even if already authenticated. Use this to switch accounts or re-authenticate. |
|
|
66
|
+
| `happy-mcp-server auth logout` | Remove saved credentials from `~/.happy-mcp/credentials.json`. |
|
|
67
|
+
| `happy-mcp-server help` | Display help message with available commands. |
|
|
52
68
|
|
|
53
69
|
### Transport Comparison
|
|
54
70
|
|
|
55
71
|
| Feature | Stdio Transport | HTTP Transport |
|
|
56
72
|
|---------|----------------|----------------|
|
|
57
73
|
| **Use case** | MCP client integration (Claude Desktop, Cursor, etc.) | Custom clients, testing, or programmatic access |
|
|
58
|
-
| **Command** | `happy-mcp` | `happy-mcp serve [--port <port>]` |
|
|
74
|
+
| **Command** | `happy-mcp-server` | `happy-mcp-server serve [--port <port>]` |
|
|
59
75
|
| **Communication** | Standard input/output streams | HTTP POST/GET/DELETE to `/mcp` endpoint |
|
|
60
76
|
| **Port** | N/A (uses stdio) | Auto-assigned or specified with `--port` |
|
|
61
77
|
| **Accessibility** | Only via client process | HTTP endpoint on localhost (`127.0.0.1`) |
|
|
@@ -68,26 +84,29 @@ Environment variables customize server behavior:
|
|
|
68
84
|
| Variable | Default | Description |
|
|
69
85
|
|----------|---------|-------------|
|
|
70
86
|
| `HAPPY_SERVER_URL` | `https://api.cluster-fluster.com` | Happy relay server URL. |
|
|
87
|
+
| `HAPPY_MCP_ACCOUNT_SECRET` | (none) | Base64-encoded 32-byte account secret for instant authentication. When set, bypasses credential file and QR pairing. Obtain via: `cat ~/.happy-mcp/credentials.json \| jq -r .secret` |
|
|
71
88
|
| `HAPPY_MCP_COMPUTERS` | `os.hostname()` | Comma-separated list of computer hostnames to filter sessions and machines. Use `*` to show all computers. |
|
|
72
89
|
| `HAPPY_MCP_PROJECT_PATHS` | `process.cwd()` | Comma-separated list of project path prefixes to filter sessions. Use `*` to show all paths. |
|
|
90
|
+
| `HAPPY_MCP_CREDENTIALS_PATH` | `~/.happy-mcp/credentials.json` | Path to credentials file |
|
|
73
91
|
| `HAPPY_MCP_LOG_LEVEL` | `warn` | Log level: `debug`, `info`, `warn`, or `error`. Logs are written to stderr. |
|
|
92
|
+
| `HAPPY_MCP_SESSION_CACHE_TTL` | `300` | Session cache TTL in seconds |
|
|
74
93
|
| `HAPPY_MCP_ENABLE_START` | `true` | Set to `false` to disable the `start_session` tool. |
|
|
75
94
|
|
|
76
95
|
## HTTP Transport
|
|
77
96
|
|
|
78
|
-
When running `happy-mcp serve`, the server exposes MCP over HTTP instead of stdio. This mode is useful for custom clients, testing, or programmatic access.
|
|
97
|
+
When running `happy-mcp-server serve`, the server exposes MCP over HTTP instead of stdio. This mode is useful for custom clients, testing, or programmatic access.
|
|
79
98
|
|
|
80
99
|
### Starting the HTTP Server
|
|
81
100
|
|
|
82
101
|
```bash
|
|
83
102
|
# Auto-assign port (reports actual port on startup)
|
|
84
|
-
happy-mcp serve
|
|
103
|
+
happy-mcp-server serve
|
|
85
104
|
|
|
86
105
|
# Specify a port
|
|
87
|
-
happy-mcp serve --port 3000
|
|
106
|
+
happy-mcp-server serve --port 3000
|
|
88
107
|
|
|
89
108
|
# With environment variables
|
|
90
|
-
HAPPY_MCP_LOG_LEVEL=debug happy-mcp serve --port 8080
|
|
109
|
+
HAPPY_MCP_LOG_LEVEL=debug happy-mcp-server serve --port 8080
|
|
91
110
|
```
|
|
92
111
|
|
|
93
112
|
### Server Endpoint
|
|
@@ -110,7 +129,7 @@ http://127.0.0.1:<port>/mcp
|
|
|
110
129
|
|
|
111
130
|
### Requirements
|
|
112
131
|
|
|
113
|
-
- **Authentication required**: You must run `happy-mcp auth` before starting the HTTP server. The server will exit with an error if credentials are not found.
|
|
132
|
+
- **Authentication required**: You must run `happy-mcp-server auth` or set `HAPPY_MCP_ACCOUNT_SECRET` before starting the HTTP server. The server will exit with an error if credentials are not found.
|
|
114
133
|
- **Credential file permissions**: The credentials file must have `0600` permissions (readable only by owner).
|
|
115
134
|
|
|
116
135
|
### Example: Testing with curl
|
|
@@ -130,7 +149,7 @@ curl -X POST http://127.0.0.1:3000/mcp \
|
|
|
130
149
|
Add to `.mcp.json` in your project root or configure via CLI:
|
|
131
150
|
|
|
132
151
|
```bash
|
|
133
|
-
claude mcp add --transport stdio happy -- happy-mcp
|
|
152
|
+
claude mcp add --transport stdio happy -- happy-mcp-server
|
|
134
153
|
```
|
|
135
154
|
|
|
136
155
|
Or manually add to `.mcp.json`:
|
|
@@ -140,7 +159,7 @@ Or manually add to `.mcp.json`:
|
|
|
140
159
|
"mcpServers": {
|
|
141
160
|
"happy": {
|
|
142
161
|
"type": "stdio",
|
|
143
|
-
"command": "happy-mcp"
|
|
162
|
+
"command": "happy-mcp-server"
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
}
|
|
@@ -153,7 +172,7 @@ Or manually add to `.mcp.json`:
|
|
|
153
172
|
"mcpServers": {
|
|
154
173
|
"happy": {
|
|
155
174
|
"type": "stdio",
|
|
156
|
-
"command": "happy-mcp",
|
|
175
|
+
"command": "happy-mcp-server",
|
|
157
176
|
"env": {
|
|
158
177
|
"HAPPY_MCP_COMPUTERS": "*",
|
|
159
178
|
"HAPPY_MCP_PROJECT_PATHS": "*"
|
|
@@ -178,7 +197,7 @@ Add to your Claude Desktop config:
|
|
|
178
197
|
"mcpServers": {
|
|
179
198
|
"happy": {
|
|
180
199
|
"type": "stdio",
|
|
181
|
-
"command": "happy-mcp"
|
|
200
|
+
"command": "happy-mcp-server"
|
|
182
201
|
}
|
|
183
202
|
}
|
|
184
203
|
}
|
|
@@ -195,7 +214,7 @@ Add to `.cursor/mcp.json` in your project or global config:
|
|
|
195
214
|
{
|
|
196
215
|
"mcpServers": {
|
|
197
216
|
"happy": {
|
|
198
|
-
"command": "happy-mcp"
|
|
217
|
+
"command": "happy-mcp-server"
|
|
199
218
|
}
|
|
200
219
|
}
|
|
201
220
|
}
|
|
@@ -212,7 +231,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
212
231
|
{
|
|
213
232
|
"mcpServers": {
|
|
214
233
|
"happy": {
|
|
215
|
-
"command": "happy-mcp"
|
|
234
|
+
"command": "happy-mcp-server"
|
|
216
235
|
}
|
|
217
236
|
}
|
|
218
237
|
}
|
|
@@ -232,7 +251,7 @@ Add to `~/.continue/config.json`:
|
|
|
232
251
|
{
|
|
233
252
|
"transport": {
|
|
234
253
|
"type": "stdio",
|
|
235
|
-
"command": "happy-mcp"
|
|
254
|
+
"command": "happy-mcp-server"
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Credentials } from './crypto.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
type PairingState = 'idle' | 'polling' | 'success';
|
|
4
|
+
export declare class PairingManager {
|
|
5
|
+
private config;
|
|
6
|
+
private onSuccess;
|
|
7
|
+
private session;
|
|
8
|
+
private state;
|
|
9
|
+
private pollTimerId;
|
|
10
|
+
private pollDeadline;
|
|
11
|
+
private isRequestInFlight;
|
|
12
|
+
private pairingComplete;
|
|
13
|
+
private pendingCredentials;
|
|
14
|
+
private destroyed;
|
|
15
|
+
private initiatePromise;
|
|
16
|
+
constructor(config: Config, onSuccess: (credentials: Credentials) => Promise<void>);
|
|
17
|
+
get currentState(): PairingState;
|
|
18
|
+
get hasSession(): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Initiate pairing: generate keypair, POST to relay, generate QR, start polling.
|
|
21
|
+
* If already initiated, restarts the polling timer and returns cached QR.
|
|
22
|
+
*/
|
|
23
|
+
initiate(): Promise<{
|
|
24
|
+
qrAscii: string;
|
|
25
|
+
webUrl: string;
|
|
26
|
+
}>;
|
|
27
|
+
private _doInitiate;
|
|
28
|
+
/**
|
|
29
|
+
* Start background polling. Fire-and-forget.
|
|
30
|
+
* If already polling, restarts the deadline timer.
|
|
31
|
+
*/
|
|
32
|
+
private startPolling;
|
|
33
|
+
private schedulePollTick;
|
|
34
|
+
private pollTick;
|
|
35
|
+
private pollOnce;
|
|
36
|
+
/**
|
|
37
|
+
* Clean up timers on shutdown.
|
|
38
|
+
*/
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import nacl from 'tweetnacl';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import qrcode from 'qrcode-terminal';
|
|
4
|
+
import { encodeBase64, encodeBase64Url, decodeBase64, decryptBoxBundle } from './crypto.js';
|
|
5
|
+
import { writeCredentials, readCredentials } from './credentials.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { AuthError } from '../errors.js';
|
|
8
|
+
const POLL_INTERVAL = 1000;
|
|
9
|
+
const AUTH_TIMEOUT = 120_000;
|
|
10
|
+
export class PairingManager {
|
|
11
|
+
config;
|
|
12
|
+
onSuccess;
|
|
13
|
+
session = null;
|
|
14
|
+
state = 'idle';
|
|
15
|
+
pollTimerId = null;
|
|
16
|
+
pollDeadline = 0;
|
|
17
|
+
isRequestInFlight = false;
|
|
18
|
+
pairingComplete = false;
|
|
19
|
+
pendingCredentials = null;
|
|
20
|
+
destroyed = false;
|
|
21
|
+
initiatePromise = null;
|
|
22
|
+
constructor(config, onSuccess) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.onSuccess = onSuccess;
|
|
25
|
+
}
|
|
26
|
+
get currentState() { return this.state; }
|
|
27
|
+
get hasSession() { return this.session !== null; }
|
|
28
|
+
/**
|
|
29
|
+
* Initiate pairing: generate keypair, POST to relay, generate QR, start polling.
|
|
30
|
+
* If already initiated, restarts the polling timer and returns cached QR.
|
|
31
|
+
*/
|
|
32
|
+
async initiate() {
|
|
33
|
+
if (this.state === 'success') {
|
|
34
|
+
return { qrAscii: '', webUrl: '' }; // Already authenticated
|
|
35
|
+
}
|
|
36
|
+
// Coalesce concurrent calls
|
|
37
|
+
if (this.initiatePromise) {
|
|
38
|
+
return this.initiatePromise;
|
|
39
|
+
}
|
|
40
|
+
// Generate session if we don't have one
|
|
41
|
+
if (!this.session) {
|
|
42
|
+
this.initiatePromise = this._doInitiate();
|
|
43
|
+
try {
|
|
44
|
+
return await this.initiatePromise;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
this.initiatePromise = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Session already exists, restart polling
|
|
51
|
+
this.startPolling();
|
|
52
|
+
return { qrAscii: this.session.qrAscii, webUrl: this.session.webUrl };
|
|
53
|
+
}
|
|
54
|
+
async _doInitiate() {
|
|
55
|
+
const seed = nacl.randomBytes(32);
|
|
56
|
+
const keypair = nacl.box.keyPair.fromSecretKey(seed);
|
|
57
|
+
const publicKeyBase64 = encodeBase64(keypair.publicKey);
|
|
58
|
+
const publicKeyBase64Url = encodeBase64Url(keypair.publicKey);
|
|
59
|
+
// POST to relay
|
|
60
|
+
try {
|
|
61
|
+
await axios.post(`${this.config.serverUrl}/v1/auth/account/request`, {
|
|
62
|
+
publicKey: publicKeyBase64,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (axios.isAxiosError(err) && err.response) {
|
|
67
|
+
throw new AuthError(`Failed to initiate pairing: server returned ${err.response.status}`);
|
|
68
|
+
}
|
|
69
|
+
throw new AuthError('Failed to initiate pairing: network error');
|
|
70
|
+
}
|
|
71
|
+
// Generate ASCII QR code with timeout
|
|
72
|
+
const qrData = `happy:///account?${publicKeyBase64Url}`;
|
|
73
|
+
const qrPromise = new Promise((resolve) => {
|
|
74
|
+
qrcode.generate(qrData, { small: true }, (code) => {
|
|
75
|
+
resolve(code);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
79
|
+
setTimeout(() => reject(new AuthError('QR code generation timed out')), 5000);
|
|
80
|
+
});
|
|
81
|
+
const qrAscii = await Promise.race([qrPromise, timeoutPromise]);
|
|
82
|
+
const webUrl = `https://app.happy.engineering/account/connect#key=${publicKeyBase64Url}`;
|
|
83
|
+
this.session = { keypair, publicKeyBase64, publicKeyBase64Url, qrAscii, webUrl };
|
|
84
|
+
this.startPolling();
|
|
85
|
+
return { qrAscii: this.session.qrAscii, webUrl: this.session.webUrl };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Start background polling. Fire-and-forget.
|
|
89
|
+
* If already polling, restarts the deadline timer.
|
|
90
|
+
*/
|
|
91
|
+
startPolling() {
|
|
92
|
+
if (this.destroyed)
|
|
93
|
+
return;
|
|
94
|
+
// Clear existing timer
|
|
95
|
+
if (this.pollTimerId !== null) {
|
|
96
|
+
clearTimeout(this.pollTimerId);
|
|
97
|
+
this.pollTimerId = null;
|
|
98
|
+
}
|
|
99
|
+
this.state = 'polling';
|
|
100
|
+
this.pollDeadline = Date.now() + AUTH_TIMEOUT;
|
|
101
|
+
this.schedulePollTick();
|
|
102
|
+
}
|
|
103
|
+
schedulePollTick() {
|
|
104
|
+
this.pollTimerId = setTimeout(() => {
|
|
105
|
+
this.pollTick();
|
|
106
|
+
}, POLL_INTERVAL);
|
|
107
|
+
}
|
|
108
|
+
async pollTick() {
|
|
109
|
+
if (this.destroyed)
|
|
110
|
+
return;
|
|
111
|
+
// Check deadline
|
|
112
|
+
if (Date.now() > this.pollDeadline) {
|
|
113
|
+
this.state = 'idle'; // expired but session/QR preserved
|
|
114
|
+
this.pollTimerId = null;
|
|
115
|
+
logger.debug('Pairing poll deadline expired');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Don't overlap with in-flight request — wait for next interval
|
|
119
|
+
if (this.isRequestInFlight) {
|
|
120
|
+
this.pollTimerId = setTimeout(() => this.pollTick(), POLL_INTERVAL);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.isRequestInFlight = true;
|
|
124
|
+
try {
|
|
125
|
+
const authorized = await this.pollOnce();
|
|
126
|
+
if (authorized) {
|
|
127
|
+
return; // Success handled in pollOnce
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
logger.debug('Pairing poll error:', err.message);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
this.isRequestInFlight = false;
|
|
135
|
+
}
|
|
136
|
+
// Schedule next tick if still within deadline and still polling
|
|
137
|
+
if (this.state === 'polling' && Date.now() < this.pollDeadline) {
|
|
138
|
+
this.schedulePollTick();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async pollOnce() {
|
|
142
|
+
if (this.destroyed)
|
|
143
|
+
return false;
|
|
144
|
+
if (!this.session)
|
|
145
|
+
return false;
|
|
146
|
+
// Bug C2 fix: If pairing already completed, only retry activation
|
|
147
|
+
if (this.pairingComplete && this.pendingCredentials) {
|
|
148
|
+
try {
|
|
149
|
+
await this.onSuccess(this.pendingCredentials);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
logger.error('Activation retry failed:', err.message);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
this.state = 'success';
|
|
156
|
+
this.pollTimerId = null;
|
|
157
|
+
this.session = null;
|
|
158
|
+
this.pendingCredentials = null;
|
|
159
|
+
logger.info('Pairing successful via MCP tool (retry)');
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
const res = await axios.post(`${this.config.serverUrl}/v1/auth/account/request`, {
|
|
163
|
+
publicKey: this.session.publicKeyBase64,
|
|
164
|
+
});
|
|
165
|
+
if (res.data.state === 'authorized') {
|
|
166
|
+
// Decrypt the account secret
|
|
167
|
+
const encryptedResponse = decodeBase64(res.data.response);
|
|
168
|
+
const accountSecret = decryptBoxBundle(encryptedResponse, this.session.keypair.secretKey);
|
|
169
|
+
if (!accountSecret) {
|
|
170
|
+
throw new AuthError('Failed to decrypt pairing response');
|
|
171
|
+
}
|
|
172
|
+
// Write credentials to disk
|
|
173
|
+
writeCredentials(this.config.credentialsPath, res.data.token, accountSecret, this.config.serverUrl);
|
|
174
|
+
// Read back to get full Credentials object
|
|
175
|
+
const creds = readCredentials(this.config.credentialsPath);
|
|
176
|
+
if (!creds) {
|
|
177
|
+
throw new AuthError('Failed to read back written credentials');
|
|
178
|
+
}
|
|
179
|
+
// Mark pairing as complete BEFORE calling onSuccess
|
|
180
|
+
this.pairingComplete = true;
|
|
181
|
+
this.pendingCredentials = creds;
|
|
182
|
+
// Fire callback FIRST (this triggers tool activation)
|
|
183
|
+
// State is set to 'success' only after onSuccess completes,
|
|
184
|
+
// so a failure leaves state as 'polling' and allows retry.
|
|
185
|
+
try {
|
|
186
|
+
await this.onSuccess(creds);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
logger.error('Pairing succeeded but activation failed:', err.message);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
this.state = 'success';
|
|
193
|
+
this.pollTimerId = null;
|
|
194
|
+
this.session = null; // Clear keypair from memory
|
|
195
|
+
this.pendingCredentials = null;
|
|
196
|
+
logger.info('Pairing successful via MCP tool');
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Clean up timers on shutdown.
|
|
203
|
+
*/
|
|
204
|
+
destroy() {
|
|
205
|
+
this.destroyed = true;
|
|
206
|
+
if (this.pollTimerId !== null) {
|
|
207
|
+
clearTimeout(this.pollTimerId);
|
|
208
|
+
this.pollTimerId = null;
|
|
209
|
+
}
|
|
210
|
+
this.state = 'idle'; // Bug C1 fix: prevent in-flight requests from rescheduling
|
|
211
|
+
this.session = null; // Clear keypair from memory
|
|
212
|
+
}
|
|
213
|
+
}
|
package/dist/auth/pairing.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function loadOrPairCredentials(config) {
|
|
|
20
20
|
logger.info('Loaded existing credentials');
|
|
21
21
|
return creds;
|
|
22
22
|
}
|
|
23
|
-
console.error('[happy-mcp] No credentials found. Starting pairing flow...');
|
|
23
|
+
console.error('[happy-mcp-server] No credentials found. Starting pairing flow...');
|
|
24
24
|
return performPairing(config);
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
@@ -72,7 +72,7 @@ export async function performPairing(config) {
|
|
|
72
72
|
}
|
|
73
73
|
// 6. Persist credentials
|
|
74
74
|
writeCredentials(config.credentialsPath, res.data.token, accountSecret, config.serverUrl);
|
|
75
|
-
console.error('[happy-mcp] Paired successfully!');
|
|
75
|
+
console.error('[happy-mcp-server] Paired successfully!');
|
|
76
76
|
const creds = readCredentials(config.credentialsPath);
|
|
77
77
|
if (!creds) {
|
|
78
78
|
throw new AuthError('Failed to read back written credentials');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Credentials } from './crypto.js';
|
|
2
|
+
/**
|
|
3
|
+
* Authenticate from a raw account secret (32 bytes) via Ed25519 challenge-response.
|
|
4
|
+
* This is used for environment-variable-based auth in ephemeral environments.
|
|
5
|
+
*
|
|
6
|
+
* Endpoint: POST /v1/auth
|
|
7
|
+
* Body: { challenge: base64, publicKey: base64, signature: base64 }
|
|
8
|
+
* Response: { success: boolean, token: string }
|
|
9
|
+
*
|
|
10
|
+
* Returns in-memory Credentials object (does NOT write to disk).
|
|
11
|
+
*/
|
|
12
|
+
export declare function authenticateFromSecret(secret: Uint8Array, serverUrl: string): Promise<Credentials>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { authChallenge, encodeBase64, deriveContentKeyPair } from './crypto.js';
|
|
3
|
+
import { logger } from '../logger.js';
|
|
4
|
+
import { AuthError } from '../errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Authenticate from a raw account secret (32 bytes) via Ed25519 challenge-response.
|
|
7
|
+
* This is used for environment-variable-based auth in ephemeral environments.
|
|
8
|
+
*
|
|
9
|
+
* Endpoint: POST /v1/auth
|
|
10
|
+
* Body: { challenge: base64, publicKey: base64, signature: base64 }
|
|
11
|
+
* Response: { success: boolean, token: string }
|
|
12
|
+
*
|
|
13
|
+
* Returns in-memory Credentials object (does NOT write to disk).
|
|
14
|
+
*/
|
|
15
|
+
export async function authenticateFromSecret(secret, serverUrl) {
|
|
16
|
+
logger.info('Authenticating via HAPPY_MCP_ACCOUNT_SECRET...');
|
|
17
|
+
const { challenge, publicKey, signature } = authChallenge(secret);
|
|
18
|
+
try {
|
|
19
|
+
const res = await axios.post(`${serverUrl}/v1/auth`, {
|
|
20
|
+
challenge: encodeBase64(challenge),
|
|
21
|
+
publicKey: encodeBase64(publicKey),
|
|
22
|
+
signature: encodeBase64(signature),
|
|
23
|
+
});
|
|
24
|
+
if (!res.data.success || !res.data.token) {
|
|
25
|
+
throw new AuthError('Secret auth failed: server returned unsuccessful response');
|
|
26
|
+
}
|
|
27
|
+
const token = res.data.token;
|
|
28
|
+
const contentKeyPair = deriveContentKeyPair(secret);
|
|
29
|
+
logger.info('Secret authentication successful');
|
|
30
|
+
return { token, secret, contentKeyPair };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err instanceof AuthError)
|
|
34
|
+
throw err;
|
|
35
|
+
if (axios.isAxiosError(err) && err.response) {
|
|
36
|
+
throw new AuthError(`Secret auth failed: server returned ${err.response.status}`);
|
|
37
|
+
}
|
|
38
|
+
throw new AuthError('Secret auth failed: network error');
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -9,5 +9,22 @@ export function loadConfig() {
|
|
|
9
9
|
const logLevel = (process.env.HAPPY_MCP_LOG_LEVEL ?? 'warn');
|
|
10
10
|
const sessionCacheTtl = parseInt(process.env.HAPPY_MCP_SESSION_CACHE_TTL ?? '300', 10);
|
|
11
11
|
const enableStart = process.env.HAPPY_MCP_ENABLE_START !== 'false';
|
|
12
|
-
|
|
12
|
+
// Parse HAPPY_MCP_ACCOUNT_SECRET env var (non-fatal: warn and ignore if invalid)
|
|
13
|
+
const accountSecretEnv = process.env.HAPPY_MCP_ACCOUNT_SECRET;
|
|
14
|
+
let accountSecret = null;
|
|
15
|
+
if (accountSecretEnv) {
|
|
16
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(accountSecretEnv)) {
|
|
17
|
+
console.error('[happy-mcp-server] WARNING: HAPPY_MCP_ACCOUNT_SECRET is not valid base64, ignoring');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const decoded = Buffer.from(accountSecretEnv, 'base64');
|
|
21
|
+
if (decoded.length !== 32) {
|
|
22
|
+
console.error('[happy-mcp-server] WARNING: HAPPY_MCP_ACCOUNT_SECRET is not 32 bytes, ignoring');
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
accountSecret = new Uint8Array(decoded);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { serverUrl, computers, projectPaths, credentialsPath, logLevel, sessionCacheTtl, enableStart, accountSecret };
|
|
13
30
|
}
|
package/dist/http.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function startHttpServer(config, api, relay, sessionManager, port =
|
|
|
52
52
|
};
|
|
53
53
|
// Create a new McpServer instance for this session
|
|
54
54
|
const server = new McpServer({
|
|
55
|
-
name: 'happy-mcp',
|
|
55
|
+
name: 'happy-mcp-server',
|
|
56
56
|
version: '0.1.0',
|
|
57
57
|
});
|
|
58
58
|
// Register all tools on this server instance
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,14 @@ import { loadConfig } from './config.js';
|
|
|
4
4
|
import { setupLogger, logger } from './logger.js';
|
|
5
5
|
import { performPairing } from './auth/pairing.js';
|
|
6
6
|
import { readCredentials, validateFilePermissions, clearCredentials } from './auth/credentials.js';
|
|
7
|
+
import { authenticateFromSecret } from './auth/secret-auth.js';
|
|
7
8
|
import { decryptFromBase64 } from './auth/crypto.js';
|
|
8
9
|
import { resolveSessionEncryptionCached, clearKeyCache } from './session/keys.js';
|
|
9
10
|
import { SessionManager } from './session/manager.js';
|
|
10
11
|
import { ApiClient } from './api/client.js';
|
|
11
12
|
import { RelayClient } from './relay/client.js';
|
|
12
13
|
import { createUnauthenticatedServer } from './server.js';
|
|
14
|
+
import { PairingManager } from './auth/pairing-manager.js';
|
|
13
15
|
import { startHttpServer } from './http.js';
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// Helpers
|
|
@@ -152,7 +154,7 @@ async function initializeAuthenticatedState(config, credentials) {
|
|
|
152
154
|
// CLI Commands
|
|
153
155
|
// ---------------------------------------------------------------------------
|
|
154
156
|
function printHelp() {
|
|
155
|
-
console.error(`Usage: happy-mcp [command] [options]
|
|
157
|
+
console.error(`Usage: happy-mcp-server [command] [options]
|
|
156
158
|
|
|
157
159
|
Commands:
|
|
158
160
|
(no args) Start the MCP server (stdio transport)
|
|
@@ -172,19 +174,19 @@ function parseServeFlags(argv) {
|
|
|
172
174
|
if (argv[i] === '--port') {
|
|
173
175
|
const raw = argv[i + 1];
|
|
174
176
|
if (raw === undefined || raw.startsWith('-')) {
|
|
175
|
-
console.error('[happy-mcp] --port requires a port number. Example: happy-mcp serve --port 3000');
|
|
177
|
+
console.error('[happy-mcp-server] --port requires a port number. Example: happy-mcp-server serve --port 3000');
|
|
176
178
|
process.exit(1);
|
|
177
179
|
}
|
|
178
180
|
const parsed = Number(raw);
|
|
179
181
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
180
|
-
console.error(`[happy-mcp] Invalid port '${raw}'. Must be an integer between 1 and 65535.`);
|
|
182
|
+
console.error(`[happy-mcp-server] Invalid port '${raw}'. Must be an integer between 1 and 65535.`);
|
|
181
183
|
process.exit(1);
|
|
182
184
|
}
|
|
183
185
|
port = parsed;
|
|
184
186
|
i++;
|
|
185
187
|
}
|
|
186
188
|
else {
|
|
187
|
-
console.error(`[happy-mcp] Unknown flag '${argv[i]}'. Run \`happy-mcp help\` for help.`);
|
|
189
|
+
console.error(`[happy-mcp-server] Unknown flag '${argv[i]}'. Run \`happy-mcp-server help\` for help.`);
|
|
188
190
|
process.exit(1);
|
|
189
191
|
}
|
|
190
192
|
}
|
|
@@ -194,9 +196,9 @@ async function runAuth() {
|
|
|
194
196
|
const config = loadConfig();
|
|
195
197
|
const creds = readCredentials(config.credentialsPath);
|
|
196
198
|
if (creds) {
|
|
197
|
-
console.error('[happy-mcp] Already authenticated.');
|
|
198
|
-
console.error(' To login with a different account: happy-mcp auth login');
|
|
199
|
-
console.error(' To logout: happy-mcp auth logout');
|
|
199
|
+
console.error('[happy-mcp-server] Already authenticated.');
|
|
200
|
+
console.error(' To login with a different account: happy-mcp-server auth login');
|
|
201
|
+
console.error(' To logout: happy-mcp-server auth logout');
|
|
200
202
|
process.exit(0);
|
|
201
203
|
}
|
|
202
204
|
await runAuthLogin();
|
|
@@ -205,7 +207,7 @@ async function runAuthLogin() {
|
|
|
205
207
|
const config = loadConfig();
|
|
206
208
|
setupLogger('info');
|
|
207
209
|
await performPairing(config);
|
|
208
|
-
console.error('[happy-mcp] Authentication complete.');
|
|
210
|
+
console.error('[happy-mcp-server] Authentication complete.');
|
|
209
211
|
process.exit(0);
|
|
210
212
|
}
|
|
211
213
|
async function runAuthLogout() {
|
|
@@ -213,12 +215,12 @@ async function runAuthLogout() {
|
|
|
213
215
|
setupLogger(config.logLevel);
|
|
214
216
|
const creds = readCredentials(config.credentialsPath);
|
|
215
217
|
if (!creds) {
|
|
216
|
-
console.error('[happy-mcp] Not currently authenticated. Nothing to do.');
|
|
218
|
+
console.error('[happy-mcp-server] Not currently authenticated. Nothing to do.');
|
|
217
219
|
process.exit(0);
|
|
218
220
|
}
|
|
219
221
|
clearCredentials(config.credentialsPath);
|
|
220
|
-
console.error('[happy-mcp] Logged out. Credentials removed.');
|
|
221
|
-
console.error(' To re-authenticate: happy-mcp auth login');
|
|
222
|
+
console.error('[happy-mcp-server] Logged out. Credentials removed.');
|
|
223
|
+
console.error(' To re-authenticate: happy-mcp-server auth login');
|
|
222
224
|
process.exit(0);
|
|
223
225
|
}
|
|
224
226
|
// ---------------------------------------------------------------------------
|
|
@@ -227,7 +229,7 @@ async function runAuthLogout() {
|
|
|
227
229
|
async function runServer() {
|
|
228
230
|
const config = loadConfig();
|
|
229
231
|
setupLogger(config.logLevel);
|
|
230
|
-
logger.info('Starting happy-mcp...');
|
|
232
|
+
logger.info('Starting happy-mcp-server...');
|
|
231
233
|
const authState = {
|
|
232
234
|
authenticated: false,
|
|
233
235
|
activatingPromise: null,
|
|
@@ -235,33 +237,30 @@ async function runServer() {
|
|
|
235
237
|
sessionManager: null,
|
|
236
238
|
};
|
|
237
239
|
let activateFn = null;
|
|
238
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Shared activation logic: initialize authenticated state and swap in real tools.
|
|
242
|
+
* All auth paths (env var, file-based, MCP pairing) converge here.
|
|
243
|
+
*/
|
|
244
|
+
async function performActivation(credentials) {
|
|
239
245
|
if (authState.authenticated)
|
|
240
246
|
return true;
|
|
241
247
|
if (authState.activatingPromise)
|
|
242
248
|
return authState.activatingPromise;
|
|
243
|
-
const creds = readCredentials(config.credentialsPath);
|
|
244
|
-
if (!creds)
|
|
245
|
-
return false;
|
|
246
|
-
try {
|
|
247
|
-
validateFilePermissions(config.credentialsPath);
|
|
248
|
-
}
|
|
249
|
-
catch (err) {
|
|
250
|
-
logger.debug('Credential file has unsafe permissions:', err.message);
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
249
|
authState.activatingPromise = (async () => {
|
|
254
250
|
try {
|
|
255
|
-
const state = await initializeAuthenticatedState(config,
|
|
251
|
+
const state = await initializeAuthenticatedState(config, credentials);
|
|
252
|
+
if (!activateFn) {
|
|
253
|
+
logger.error('activate function not ready yet');
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
activateFn(state.api, state.relay, state.sessionManager);
|
|
256
257
|
authState.relay = state.relay;
|
|
257
258
|
authState.sessionManager = state.sessionManager;
|
|
258
|
-
activateFn(state.api, state.relay, state.sessionManager);
|
|
259
259
|
authState.authenticated = true;
|
|
260
|
-
logger.info('Lazy authentication complete -- tools are now active');
|
|
261
260
|
return true;
|
|
262
261
|
}
|
|
263
262
|
catch (err) {
|
|
264
|
-
logger.debug('
|
|
263
|
+
logger.debug('Activation failed:', err.message);
|
|
265
264
|
return false;
|
|
266
265
|
}
|
|
267
266
|
finally {
|
|
@@ -270,31 +269,78 @@ async function runServer() {
|
|
|
270
269
|
})();
|
|
271
270
|
return authState.activatingPromise;
|
|
272
271
|
}
|
|
273
|
-
|
|
272
|
+
async function tryActivate() {
|
|
273
|
+
if (authState.authenticated)
|
|
274
|
+
return true;
|
|
275
|
+
if (authState.activatingPromise)
|
|
276
|
+
return authState.activatingPromise;
|
|
277
|
+
const creds = readCredentials(config.credentialsPath);
|
|
278
|
+
if (!creds)
|
|
279
|
+
return false;
|
|
280
|
+
try {
|
|
281
|
+
validateFilePermissions(config.credentialsPath);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
logger.debug('Credential file has unsafe permissions:', err.message);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
const result = await performActivation(creds);
|
|
288
|
+
if (result)
|
|
289
|
+
logger.info('Lazy authentication complete -- tools are now active');
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
// Create PairingManager for in-process QR pairing
|
|
293
|
+
const pairingManager = new PairingManager(config, async (credentials) => {
|
|
294
|
+
const result = await performActivation(credentials);
|
|
295
|
+
if (result) {
|
|
296
|
+
logger.info('MCP-based authentication complete — tools are now active');
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
throw new Error('Activation failed after successful pairing');
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
const { server, activate } = createUnauthenticatedServer(config, tryActivate, pairingManager);
|
|
274
303
|
activateFn = activate;
|
|
275
304
|
// Connect stdio transport FIRST (Issue 1: prevent blocking)
|
|
276
305
|
const transport = new StdioServerTransport();
|
|
277
306
|
await server.connect(transport);
|
|
278
307
|
logger.info(`MCP server started — credentials path: ${config.credentialsPath}`);
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
if (initialCreds) {
|
|
308
|
+
// Priority 1: HAPPY_MCP_ACCOUNT_SECRET env var
|
|
309
|
+
if (config.accountSecret) {
|
|
282
310
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
311
|
+
const creds = await authenticateFromSecret(config.accountSecret, config.serverUrl);
|
|
312
|
+
await performActivation(creds);
|
|
313
|
+
if (authState.authenticated) {
|
|
314
|
+
logger.info('Authenticated via HAPPY_MCP_ACCOUNT_SECRET');
|
|
315
|
+
}
|
|
286
316
|
}
|
|
287
317
|
catch (err) {
|
|
288
|
-
logger.
|
|
318
|
+
logger.warn('HAPPY_MCP_ACCOUNT_SECRET auth failed:', err.message);
|
|
319
|
+
// Fall through to file-based / QR auth
|
|
289
320
|
}
|
|
290
321
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
322
|
+
// Priority 2: Existing credentials file
|
|
323
|
+
if (!authState.authenticated) {
|
|
324
|
+
const initialCreds = readCredentials(config.credentialsPath);
|
|
325
|
+
if (initialCreds) {
|
|
326
|
+
try {
|
|
327
|
+
validateFilePermissions(config.credentialsPath);
|
|
328
|
+
logger.info('Credentials found at startup, initializing...');
|
|
329
|
+
await tryActivate();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
logger.debug('Failed to initialize with existing credentials:', err.message);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
logger.debug('No credentials found. Tools will attempt auth on each call.');
|
|
337
|
+
logger.debug('Run `happy-mcp-server auth` to authenticate.');
|
|
338
|
+
}
|
|
294
339
|
}
|
|
295
340
|
logger.info(`MCP server started (${authState.authenticated ? 'authenticated' : 'unauthenticated'} mode)`);
|
|
296
341
|
const shutdown = async () => {
|
|
297
342
|
logger.info('Shutting down...');
|
|
343
|
+
pairingManager.destroy();
|
|
298
344
|
authState.relay?.disconnect();
|
|
299
345
|
authState.sessionManager?.destroy();
|
|
300
346
|
clearKeyCache();
|
|
@@ -318,19 +364,35 @@ async function runServer() {
|
|
|
318
364
|
async function runHttpServer(port) {
|
|
319
365
|
const config = loadConfig();
|
|
320
366
|
setupLogger(config.logLevel);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
367
|
+
// Priority 1: HAPPY_MCP_ACCOUNT_SECRET env var
|
|
368
|
+
let creds = null;
|
|
369
|
+
if (config.accountSecret) {
|
|
370
|
+
try {
|
|
371
|
+
creds = await authenticateFromSecret(config.accountSecret, config.serverUrl);
|
|
372
|
+
logger.info('HTTP server authenticated via HAPPY_MCP_ACCOUNT_SECRET');
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
logger.warn('HAPPY_MCP_ACCOUNT_SECRET auth failed:', err.message);
|
|
376
|
+
}
|
|
325
377
|
}
|
|
326
|
-
|
|
327
|
-
|
|
378
|
+
// Priority 2: Credentials file
|
|
379
|
+
if (!creds) {
|
|
380
|
+
creds = readCredentials(config.credentialsPath);
|
|
381
|
+
if (creds) {
|
|
382
|
+
try {
|
|
383
|
+
validateFilePermissions(config.credentialsPath);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
console.error('[happy-mcp-server] Credential file has unsafe permissions:', err.message);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
328
390
|
}
|
|
329
|
-
|
|
330
|
-
console.error('[happy-mcp]
|
|
391
|
+
if (!creds) {
|
|
392
|
+
console.error('[happy-mcp-server] No credentials found. Run `happy-mcp-server auth` or set HAPPY_MCP_ACCOUNT_SECRET.');
|
|
331
393
|
process.exit(1);
|
|
332
394
|
}
|
|
333
|
-
logger.info('Starting happy-mcp HTTP server...');
|
|
395
|
+
logger.info('Starting happy-mcp-server HTTP server...');
|
|
334
396
|
const state = await initializeAuthenticatedState(config, creds);
|
|
335
397
|
let result;
|
|
336
398
|
try {
|
|
@@ -339,17 +401,17 @@ async function runHttpServer(port) {
|
|
|
339
401
|
catch (error) {
|
|
340
402
|
const err = error;
|
|
341
403
|
if (err.code === 'EADDRINUSE') {
|
|
342
|
-
console.error(`[happy-mcp] Port ${port} is already in use.`);
|
|
404
|
+
console.error(`[happy-mcp-server] Port ${port} is already in use.`);
|
|
343
405
|
}
|
|
344
406
|
else if (err.code === 'EACCES') {
|
|
345
|
-
console.error(`[happy-mcp] Permission denied binding to port ${port}. Try a port above 1023.`);
|
|
407
|
+
console.error(`[happy-mcp-server] Permission denied binding to port ${port}. Try a port above 1023.`);
|
|
346
408
|
}
|
|
347
409
|
else {
|
|
348
|
-
console.error(`[happy-mcp] Failed to start HTTP server: ${err.message ?? err}`);
|
|
410
|
+
console.error(`[happy-mcp-server] Failed to start HTTP server: ${err.message ?? err}`);
|
|
349
411
|
}
|
|
350
412
|
process.exit(1);
|
|
351
413
|
}
|
|
352
|
-
console.error(`[happy-mcp] HTTP server listening on http://127.0.0.1:${result.port}/mcp`);
|
|
414
|
+
console.error(`[happy-mcp-server] HTTP server listening on http://127.0.0.1:${result.port}/mcp`);
|
|
353
415
|
const shutdown = async () => {
|
|
354
416
|
logger.info('Shutting down HTTP server...');
|
|
355
417
|
await result.close();
|
|
@@ -367,14 +429,14 @@ async function runHttpServer(port) {
|
|
|
367
429
|
const command = process.argv[2];
|
|
368
430
|
if (!command) {
|
|
369
431
|
runServer().catch((err) => {
|
|
370
|
-
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
432
|
+
console.error('[happy-mcp-server] Fatal:', err.message ?? err);
|
|
371
433
|
process.exit(1);
|
|
372
434
|
});
|
|
373
435
|
}
|
|
374
436
|
else if (command === 'serve') {
|
|
375
437
|
const flags = parseServeFlags(process.argv.slice(3));
|
|
376
438
|
runHttpServer(flags.port).catch((err) => {
|
|
377
|
-
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
439
|
+
console.error('[happy-mcp-server] Fatal:', err.message ?? err);
|
|
378
440
|
process.exit(1);
|
|
379
441
|
});
|
|
380
442
|
}
|
|
@@ -382,24 +444,24 @@ else if (command === 'auth') {
|
|
|
382
444
|
const subcommand = process.argv[3];
|
|
383
445
|
if (!subcommand) {
|
|
384
446
|
runAuth().catch((err) => {
|
|
385
|
-
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
447
|
+
console.error('[happy-mcp-server] Fatal:', err.message ?? err);
|
|
386
448
|
process.exit(1);
|
|
387
449
|
});
|
|
388
450
|
}
|
|
389
451
|
else if (subcommand === 'login') {
|
|
390
452
|
runAuthLogin().catch((err) => {
|
|
391
|
-
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
453
|
+
console.error('[happy-mcp-server] Fatal:', err.message ?? err);
|
|
392
454
|
process.exit(1);
|
|
393
455
|
});
|
|
394
456
|
}
|
|
395
457
|
else if (subcommand === 'logout') {
|
|
396
458
|
runAuthLogout().catch((err) => {
|
|
397
|
-
console.error('[happy-mcp] Fatal:', err.message ?? err);
|
|
459
|
+
console.error('[happy-mcp-server] Fatal:', err.message ?? err);
|
|
398
460
|
process.exit(1);
|
|
399
461
|
});
|
|
400
462
|
}
|
|
401
463
|
else {
|
|
402
|
-
console.error(`[happy-mcp] Unknown auth command '${subcommand}'. Run \`happy-mcp help\` for help.`);
|
|
464
|
+
console.error(`[happy-mcp-server] Unknown auth command '${subcommand}'. Run \`happy-mcp-server help\` for help.`);
|
|
403
465
|
process.exit(1);
|
|
404
466
|
}
|
|
405
467
|
}
|
|
@@ -408,6 +470,6 @@ else if (command === 'help') {
|
|
|
408
470
|
process.exit(0);
|
|
409
471
|
}
|
|
410
472
|
else {
|
|
411
|
-
console.error(`[happy-mcp] Unknown command '${command}'. Run \`happy-mcp help\` for help.`);
|
|
473
|
+
console.error(`[happy-mcp-server] Unknown command '${command}'. Run \`happy-mcp-server help\` for help.`);
|
|
412
474
|
process.exit(1);
|
|
413
475
|
}
|
package/dist/logger.js
CHANGED
|
@@ -10,7 +10,7 @@ export function setupLogger(level) {
|
|
|
10
10
|
}
|
|
11
11
|
function log(level, ...args) {
|
|
12
12
|
if (LEVELS[level] >= currentLevel) {
|
|
13
|
-
const prefix = `[happy-mcp] [${level.toUpperCase()}]`;
|
|
13
|
+
const prefix = `[happy-mcp-server] [${level.toUpperCase()}]`;
|
|
14
14
|
console.error(prefix, ...args);
|
|
15
15
|
}
|
|
16
16
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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
|
+
import type { PairingManager } from './auth/pairing-manager.js';
|
|
6
7
|
/**
|
|
7
8
|
* Register all real tool handlers on an McpServer instance.
|
|
8
9
|
* Used by both stdio (via activate()) and HTTP (directly).
|
|
@@ -18,4 +19,4 @@ export interface UnauthenticatedServer {
|
|
|
18
19
|
* The callback should attempt authentication and return true on success.
|
|
19
20
|
* Call activate() to swap in real tool handlers.
|
|
20
21
|
*/
|
|
21
|
-
export declare function createUnauthenticatedServer(config: Config, onToolCallWhileUnauthenticated: () => Promise<boolean
|
|
22
|
+
export declare function createUnauthenticatedServer(config: Config, onToolCallWhileUnauthenticated: () => Promise<boolean>, pairingManager?: PairingManager): UnauthenticatedServer;
|
package/dist/server.js
CHANGED
|
@@ -13,7 +13,7 @@ const AUTH_ERROR = {
|
|
|
13
13
|
isError: true,
|
|
14
14
|
content: [{
|
|
15
15
|
type: 'text',
|
|
16
|
-
text: 'Not authenticated.
|
|
16
|
+
text: 'Not authenticated. Call the `authentication_status` tool to begin authentication.',
|
|
17
17
|
}],
|
|
18
18
|
};
|
|
19
19
|
const AUTH_RETRY = {
|
|
@@ -57,15 +57,16 @@ export function registerAllTools(server, config, api, relay, sessionManager) {
|
|
|
57
57
|
* The callback should attempt authentication and return true on success.
|
|
58
58
|
* Call activate() to swap in real tool handlers.
|
|
59
59
|
*/
|
|
60
|
-
export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticated) {
|
|
60
|
+
export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticated, pairingManager) {
|
|
61
61
|
const server = new McpServer({
|
|
62
|
-
name: 'happy-mcp',
|
|
62
|
+
name: 'happy-mcp-server',
|
|
63
63
|
version: '0.1.0',
|
|
64
64
|
});
|
|
65
65
|
let stubRegistrations = [];
|
|
66
|
+
let activated = false;
|
|
66
67
|
const stubHandler = async () => {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
68
|
+
const didActivate = await onToolCallWhileUnauthenticated();
|
|
69
|
+
if (didActivate) {
|
|
69
70
|
return AUTH_RETRY;
|
|
70
71
|
}
|
|
71
72
|
return AUTH_ERROR;
|
|
@@ -81,10 +82,56 @@ export function createUnauthenticatedServer(config, onToolCallWhileUnauthenticat
|
|
|
81
82
|
}
|
|
82
83
|
// Start with stubs
|
|
83
84
|
registerStubs();
|
|
85
|
+
// Register authentication_status tool OUTSIDE of stubRegistrations
|
|
86
|
+
// so it is NOT removed by activate()
|
|
87
|
+
if (pairingManager) {
|
|
88
|
+
server.tool('authentication_status', 'Check authentication status. If not authenticated, returns a QR code to scan with the Happy mobile app. Display the QR code to the user. Background polling will automatically detect when authentication completes.', {}, async () => {
|
|
89
|
+
if (activated || pairingManager.currentState === 'success') {
|
|
90
|
+
return {
|
|
91
|
+
content: [{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: 'Authenticated. All tools are active.',
|
|
94
|
+
}],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const { qrAscii, webUrl } = await pairingManager.initiate();
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text: qrAscii,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: 'text',
|
|
107
|
+
text: [
|
|
108
|
+
'Scan the QR code above with the Happy mobile app to authenticate.',
|
|
109
|
+
'',
|
|
110
|
+
`Or visit: ${webUrl}`,
|
|
111
|
+
'',
|
|
112
|
+
'Authentication will complete automatically once scanned.',
|
|
113
|
+
'You can then retry your previous request.',
|
|
114
|
+
].join('\n'),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
isError: true,
|
|
122
|
+
content: [{
|
|
123
|
+
type: 'text',
|
|
124
|
+
text: `Authentication failed: ${err.message}`,
|
|
125
|
+
}],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
84
130
|
return {
|
|
85
131
|
server,
|
|
86
132
|
activate(api, relay, sessionManager) {
|
|
87
|
-
|
|
133
|
+
activated = true;
|
|
134
|
+
// Remove all stubs synchronously (authentication_status is NOT in this array)
|
|
88
135
|
for (const stub of stubRegistrations) {
|
|
89
136
|
stub.remove();
|
|
90
137
|
}
|
|
@@ -45,7 +45,7 @@ export function registerAnswerQuestion(server, api, relay, sessionManager) {
|
|
|
45
45
|
const content = {
|
|
46
46
|
role: 'user',
|
|
47
47
|
content: { type: 'text', text: answerText },
|
|
48
|
-
meta: { sentFrom: 'happy-mcp' },
|
|
48
|
+
meta: { sentFrom: 'happy-mcp-server' },
|
|
49
49
|
};
|
|
50
50
|
const encrypted = encryptToBase64(session.encryption, content);
|
|
51
51
|
await api.sendMessages(sessionId, [{ content: encrypted, localId: randomUUID() }]);
|
|
@@ -31,7 +31,7 @@ export function registerSendMessage(server, relay, sessionManager) {
|
|
|
31
31
|
role: 'user',
|
|
32
32
|
content: { type: 'text', text: message },
|
|
33
33
|
meta: {
|
|
34
|
-
sentFrom: 'happy-mcp',
|
|
34
|
+
sentFrom: 'happy-mcp-server',
|
|
35
35
|
...(meta?.permissionMode ? { permissionMode: meta.permissionMode } : {}),
|
|
36
36
|
...(meta?.allowedTools ? { allowedTools: meta.allowedTools } : {}),
|
|
37
37
|
...(meta?.disallowedTools ? { disallowedTools: meta.disallowedTools } : {}),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
|
|
3
|
-
const MAX_WAIT_MS =
|
|
3
|
+
const MAX_WAIT_MS = 300_000; // 5 min max wait
|
|
4
4
|
export function registerWatchSession(server, relay, sessionManager) {
|
|
5
|
-
return server.tool('watch_session', 'Watch one or more sessions for state changes. Returns immediately if any session is idle or has pending permissions. Otherwise waits up to
|
|
5
|
+
return server.tool('watch_session', 'Watch one or more sessions for state changes. Returns immediately if any session is idle or has pending permissions. Otherwise waits up to 5 minutes for a state change. If sessions are still active, returns current status -- call again to continue watching.', {
|
|
6
6
|
sessionIds: z.array(z.string()).describe('One or more session IDs to watch'),
|
|
7
7
|
timeoutSeconds: z.number().optional().default(DEFAULT_TIMEOUT_SECONDS).describe('Max wait time in seconds (default 300)'),
|
|
8
8
|
}, async ({ sessionIds, timeoutSeconds }, extra) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happy-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "MCP server for observing and controlling Happy Coder sessions",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Jared Spencer",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"type": "module",
|
|
32
32
|
"main": "dist/index.js",
|
|
33
33
|
"bin": {
|
|
34
|
-
"happy-mcp": "dist/index.js"
|
|
34
|
+
"happy-mcp-server": "dist/index.js"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc && chmod +x dist/index.js",
|