@gethmy/mcp 2.9.4 → 2.9.6

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.
@@ -0,0 +1,288 @@
1
+ // Loopback + PKCE OAuth login for `npx @gethmy/mcp setup` (card #364).
2
+ //
3
+ // Replaces passing a long-lived, unscoped API key through argv. The CLI binds
4
+ // an ephemeral loopback port, dynamically registers a public OAuth client whose
5
+ // redirect_uri is that exact `http://127.0.0.1:{port}/callback`, opens the
6
+ // browser to the consent page, and exchanges the returned code (+ PKCE verifier)
7
+ // for a workspace-scoped, expiring access/refresh token pair.
8
+ //
9
+ // No backend change: the OAuth authorization server already requires PKCE S256,
10
+ // already permits loopback redirect URIs (RFC 8252), and `harmony-api` already
11
+ // accepts `hmy_at_` access tokens on the `X-API-Key` header. See card #364.
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { createServer } from "node:http";
16
+ import type { AddressInfo } from "node:net";
17
+
18
+ // The MCP resource the token is bound to (RFC 8707). harmony-api does not check
19
+ // audience, so a token bound to the MCP resource authenticates against the REST
20
+ // API too. Overridable for self-host / staging.
21
+ const MCP_RESOURCE_URL =
22
+ process.env.HARMONY_MCP_RESOURCE || "https://mcp.gethmy.com";
23
+
24
+ // How long to wait for the user to complete browser consent before giving up.
25
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
26
+
27
+ export interface OAuthTokens {
28
+ accessToken: string;
29
+ refreshToken: string;
30
+ /** Epoch ms at which the access token expires. */
31
+ expiresAt: number;
32
+ clientId: string;
33
+ }
34
+
35
+ function base64Url(buf: Buffer): string {
36
+ return buf
37
+ .toString("base64")
38
+ .replace(/\+/g, "-")
39
+ .replace(/\//g, "_")
40
+ .replace(/=+$/, "");
41
+ }
42
+
43
+ /**
44
+ * RFC 7636 PKCE pair. The verifier is 43 chars of base64url (32 random bytes),
45
+ * which is within the spec's 43–128 unreserved-char range.
46
+ */
47
+ export function generatePkce(): { verifier: string; challenge: string } {
48
+ const verifier = base64Url(randomBytes(32));
49
+ const challenge = base64Url(createHash("sha256").update(verifier).digest());
50
+ return { verifier, challenge };
51
+ }
52
+
53
+ /** Derive the OAuth endpoint base (`https://host/oauth`) from the REST apiUrl. */
54
+ export function oauthBaseFromApiUrl(apiUrl: string): string {
55
+ return `${new URL(apiUrl).origin}/oauth`;
56
+ }
57
+
58
+ /** Best-effort cross-platform "open this URL in the default browser". */
59
+ function openBrowser(url: string): void {
60
+ const platform = process.platform;
61
+ const cmd =
62
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
63
+ // cmd.exe treats `&` as a command separator, so the authorize URL's
64
+ // `&`-joined query params (PKCE challenge, state, …) must be caret-escaped;
65
+ // otherwise it opens a URL truncated at the first param and runs the rest as
66
+ // bogus commands, silently breaking auto-open (the printed URL still works).
67
+ const args =
68
+ platform === "win32" ? ["/c", "start", "", url.replace(/&/g, "^&")] : [url];
69
+ try {
70
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
71
+ child.on("error", () => {
72
+ /* swallow — the printed URL is the fallback */
73
+ });
74
+ child.unref();
75
+ } catch {
76
+ // Headless / no browser — the caller already printed the URL.
77
+ }
78
+ }
79
+
80
+ interface DcrResponse {
81
+ client_id: string;
82
+ }
83
+
84
+ interface TokenResponse {
85
+ access_token: string;
86
+ refresh_token: string;
87
+ expires_in: number;
88
+ }
89
+
90
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Harmony connected</title></head>
91
+ <body style="font-family:system-ui,sans-serif;background:#0f1729;color:#dce4ef;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
92
+ <div style="text-align:center"><h1 style="color:#57b8a5">✓ Connected to Harmony</h1>
93
+ <p>You can close this tab and return to your terminal.</p></div></body></html>`;
94
+
95
+ const FAILURE_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Harmony</title></head>
96
+ <body style="font-family:system-ui,sans-serif;background:#0f1729;color:#dce4ef;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
97
+ <div style="text-align:center"><h1 style="color:#ff6b6b">Authorization failed</h1>
98
+ <p>Return to your terminal for details.</p></div></body></html>`;
99
+
100
+ /**
101
+ * Run the full loopback + PKCE browser login. Resolves with the token pair, or
102
+ * rejects on timeout / consent failure. `onUrl` receives the authorize URL so
103
+ * the caller can print it as a no-browser fallback.
104
+ */
105
+ export async function loginWithBrowser(opts: {
106
+ apiUrl: string;
107
+ workspaceId?: string;
108
+ onUrl?: (url: string) => void;
109
+ }): Promise<OAuthTokens> {
110
+ const base = oauthBaseFromApiUrl(opts.apiUrl);
111
+ const { verifier, challenge } = generatePkce();
112
+ const state = base64Url(randomBytes(16));
113
+
114
+ // 1. Bind an ephemeral loopback port BEFORE registering, so the redirect_uri
115
+ // we register is the exact one the server will receive.
116
+ const { server, port, waitForCode } = await startLoopbackServer(state);
117
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
118
+
119
+ try {
120
+ // 2. Dynamically register a throwaway public client for this exact redirect.
121
+ const clientId = await registerClient(base, redirectUri);
122
+
123
+ // 3. Build the authorize URL and open the browser.
124
+ const authorizeUrl = new URL(`${base}/authorize`);
125
+ authorizeUrl.searchParams.set("response_type", "code");
126
+ authorizeUrl.searchParams.set("client_id", clientId);
127
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
128
+ authorizeUrl.searchParams.set("code_challenge", challenge);
129
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
130
+ authorizeUrl.searchParams.set("state", state);
131
+ authorizeUrl.searchParams.set("scope", "mcp");
132
+ authorizeUrl.searchParams.set("resource", MCP_RESOURCE_URL);
133
+ if (opts.workspaceId) {
134
+ authorizeUrl.searchParams.set("workspace_id", opts.workspaceId);
135
+ }
136
+ const authUrlStr = authorizeUrl.toString();
137
+ opts.onUrl?.(authUrlStr);
138
+ openBrowser(authUrlStr);
139
+
140
+ // 4. Wait for the browser redirect to our loopback listener.
141
+ const code = await waitForCode;
142
+
143
+ // 5. Exchange the code (+ verifier) for tokens.
144
+ return await exchangeCode(base, {
145
+ code,
146
+ redirectUri,
147
+ clientId,
148
+ verifier,
149
+ });
150
+ } finally {
151
+ server.close();
152
+ }
153
+ }
154
+
155
+ async function registerClient(
156
+ base: string,
157
+ redirectUri: string,
158
+ ): Promise<string> {
159
+ const res = await fetch(`${base}/register`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({
163
+ client_name: "Harmony CLI (setup)",
164
+ redirect_uris: [redirectUri],
165
+ grant_types: ["authorization_code", "refresh_token"],
166
+ response_types: ["code"],
167
+ token_endpoint_auth_method: "none",
168
+ }),
169
+ });
170
+ const body = (await res.json().catch(() => ({}))) as Partial<DcrResponse> & {
171
+ error_description?: string;
172
+ };
173
+ if (!res.ok || !body.client_id) {
174
+ throw new Error(
175
+ body.error_description ||
176
+ `Could not register OAuth client (HTTP ${res.status})`,
177
+ );
178
+ }
179
+ return body.client_id;
180
+ }
181
+
182
+ async function exchangeCode(
183
+ base: string,
184
+ params: {
185
+ code: string;
186
+ redirectUri: string;
187
+ clientId: string;
188
+ verifier: string;
189
+ },
190
+ ): Promise<OAuthTokens> {
191
+ const res = await fetch(`${base}/token`, {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
194
+ body: new URLSearchParams({
195
+ grant_type: "authorization_code",
196
+ code: params.code,
197
+ redirect_uri: params.redirectUri,
198
+ client_id: params.clientId,
199
+ code_verifier: params.verifier,
200
+ }).toString(),
201
+ });
202
+ const body = (await res
203
+ .json()
204
+ .catch(() => ({}))) as Partial<TokenResponse> & {
205
+ error_description?: string;
206
+ };
207
+ if (!res.ok || !body.access_token || !body.refresh_token) {
208
+ throw new Error(
209
+ body.error_description || `Token exchange failed (HTTP ${res.status})`,
210
+ );
211
+ }
212
+ return {
213
+ accessToken: body.access_token,
214
+ refreshToken: body.refresh_token,
215
+ expiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
216
+ clientId: params.clientId,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Start a one-shot loopback HTTP server. Resolves the `waitForCode` promise when
222
+ * `/callback?code=…&state=…` arrives with a matching `state`. Rejects on an
223
+ * OAuth error redirect, a state mismatch, or timeout.
224
+ */
225
+ function startLoopbackServer(expectedState: string): Promise<{
226
+ server: ReturnType<typeof createServer>;
227
+ port: number;
228
+ waitForCode: Promise<string>;
229
+ }> {
230
+ return new Promise((resolveOuter, rejectOuter) => {
231
+ let resolveCode: (code: string) => void;
232
+ let rejectCode: (err: Error) => void;
233
+ const waitForCode = new Promise<string>((res, rej) => {
234
+ resolveCode = res;
235
+ rejectCode = rej;
236
+ });
237
+
238
+ const timeout = setTimeout(() => {
239
+ rejectCode(
240
+ new Error("Timed out waiting for browser authorization (5 min)."),
241
+ );
242
+ }, LOGIN_TIMEOUT_MS);
243
+ // Don't let the pending timer keep the process alive on its own.
244
+ timeout.unref?.();
245
+
246
+ const server = createServer((req, res) => {
247
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
248
+ if (url.pathname !== "/callback") {
249
+ res.writeHead(404).end();
250
+ return;
251
+ }
252
+ clearTimeout(timeout);
253
+ const error = url.searchParams.get("error");
254
+ const code = url.searchParams.get("code");
255
+ const state = url.searchParams.get("state");
256
+
257
+ const fail = (msg: string) => {
258
+ res.writeHead(400, { "Content-Type": "text/html" }).end(FAILURE_HTML);
259
+ rejectCode(new Error(msg));
260
+ };
261
+
262
+ if (error) {
263
+ return fail(
264
+ `Authorization denied: ${url.searchParams.get("error_description") || error}`,
265
+ );
266
+ }
267
+ if (!state || state !== expectedState) {
268
+ return fail("State mismatch — possible CSRF, aborting.");
269
+ }
270
+ if (!code) {
271
+ return fail("No authorization code returned.");
272
+ }
273
+ res.writeHead(200, { "Content-Type": "text/html" }).end(SUCCESS_HTML);
274
+ resolveCode(code);
275
+ });
276
+
277
+ server.on("error", (err) => {
278
+ clearTimeout(timeout);
279
+ rejectOuter(err);
280
+ });
281
+
282
+ // Bind to loopback on an ephemeral port (0 → OS assigns a free one).
283
+ server.listen(0, "127.0.0.1", () => {
284
+ const addr = server.address() as AddressInfo;
285
+ resolveOuter({ server, port: addr.port, waitForCode });
286
+ });
287
+ });
288
+ }
@@ -0,0 +1,209 @@
1
+ // OAuth access-token refresh for the running MCP server (card #364).
2
+ //
3
+ // The stdio server owns its credential (unlike the remote server, which gets a
4
+ // fresh Bearer per request). Refresh is reactive: when harmony-api returns 401
5
+ // the api-client calls this to exchange the rotating refresh token for a new
6
+ // pair, persist it, and retry. There is no proactive near-expiry refresh — the
7
+ // stored access token is sent until a 401 proves it stale, which is why
8
+ // `oauthExpiresAt` is persisted but not consulted here. Returns the new access
9
+ // token, or null if refresh is impossible (no refresh token) or the grant was
10
+ // rejected (token revoked / expired — the user must re-run `setup`).
11
+
12
+ import {
13
+ closeSync,
14
+ openSync,
15
+ renameSync,
16
+ rmSync,
17
+ statSync,
18
+ writeSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { getConfigDir, loadConfig, saveConfig } from "./config.js";
22
+ import { oauthBaseFromApiUrl } from "./oauth-login.js";
23
+
24
+ interface RefreshResponse {
25
+ access_token: string;
26
+ refresh_token: string;
27
+ expires_in: number;
28
+ }
29
+
30
+ // In-process dedup — a burst of in-flight requests all hitting 401 should
31
+ // trigger one refresh, not N (the first consumes the refresh token; the rest
32
+ // would trip reuse detection and revoke the whole family).
33
+ let inFlight: Promise<string | null> | null = null;
34
+
35
+ // Cross-process serialization. Every Claude Code session spawns its own stdio
36
+ // MCP process, and they all share ~/.harmony-mcp/config.json. The refresh
37
+ // token rotates on use, so two processes refreshing concurrently would each
38
+ // POST a refresh: the first consumes the token, the second replays a now-
39
+ // consumed token and trips the server's reuse detection, revoking the whole
40
+ // grant family and logging every session out. A lockfile mutex serializes the
41
+ // refresh across processes, and the holder re-reads config inside the lock so a
42
+ // process that lost the race adopts the freshly-rotated token instead of
43
+ // replaying its stale one.
44
+ const LOCK_FILENAME = "refresh.lock";
45
+ // A live holder releases in well under a second; this only bounds how long we
46
+ // wait on a peer that crashed mid-refresh before we treat its lock as stale.
47
+ const LOCK_STALE_MS = 30_000;
48
+ // Acquire timeout sits above the stale threshold so a dead holder's lock is
49
+ // always taken over rather than falling through unlocked.
50
+ const LOCK_ACQUIRE_TIMEOUT_MS = 35_000;
51
+ const LOCK_RETRY_MS = 100;
52
+
53
+ function lockPath(): string {
54
+ return join(getConfigDir(), LOCK_FILENAME);
55
+ }
56
+
57
+ function sleep(ms: number): Promise<void> {
58
+ return new Promise((resolve) => setTimeout(resolve, ms));
59
+ }
60
+
61
+ /**
62
+ * Acquire the cross-process refresh lock, run `fn`, and release. If the lock
63
+ * can't be acquired within the timeout (a peer wedged without crashing), `fn`
64
+ * runs unlocked anyway — a stuck peer must not permanently block refresh, and
65
+ * the in-lock re-read in `doRefresh` still guards the common race.
66
+ */
67
+ async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
68
+ const path = lockPath();
69
+ const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
70
+ let held = false;
71
+
72
+ while (Date.now() < deadline) {
73
+ try {
74
+ // wx: exclusive create — fails with EEXIST if another process holds it.
75
+ const fd = openSync(path, "wx");
76
+ writeSync(fd, String(process.pid));
77
+ closeSync(fd);
78
+ held = true;
79
+ break;
80
+ } catch (err) {
81
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
82
+ // Can't even attempt the lock (e.g. dir perms) — proceed unlocked.
83
+ break;
84
+ }
85
+ // Lock exists. Take it over if it's stale (holder crashed without
86
+ // releasing); otherwise back off and retry.
87
+ try {
88
+ const age = Date.now() - statSync(path).mtimeMs;
89
+ if (age > LOCK_STALE_MS) {
90
+ // Claim the stale lock by atomically renaming it aside, rather than
91
+ // stat-then-rmSync: that gap let two processes both delete a stale
92
+ // lock and both proceed, re-introducing the concurrent refresh this
93
+ // mutex exists to prevent. rename is atomic, so only the process
94
+ // whose rename wins owns the takeover; a loser's rename throws
95
+ // (the file is already gone) and it falls back to the retry loop.
96
+ const claim = `${path}.stale.${process.pid}`;
97
+ try {
98
+ renameSync(path, claim);
99
+ rmSync(claim, { force: true });
100
+ } catch {
101
+ // Lost the takeover race — retry against the winner's fresh lock.
102
+ }
103
+ continue;
104
+ }
105
+ } catch {
106
+ // Lock vanished between open and stat — retry immediately.
107
+ continue;
108
+ }
109
+ await sleep(LOCK_RETRY_MS);
110
+ }
111
+ }
112
+
113
+ try {
114
+ return await fn();
115
+ } finally {
116
+ if (held) {
117
+ try {
118
+ rmSync(path, { force: true });
119
+ } catch {
120
+ // Best-effort release; stale takeover covers a missed unlink.
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Refresh the stored OAuth tokens and persist the new pair. Concurrent callers
128
+ * share a single in-flight refresh in-process and serialize across processes.
129
+ * Returns the new access token or null.
130
+ */
131
+ export function refreshOAuthToken(): Promise<string | null> {
132
+ if (inFlight) return inFlight;
133
+ inFlight = doRefresh().finally(() => {
134
+ inFlight = null;
135
+ });
136
+ return inFlight;
137
+ }
138
+
139
+ async function doRefresh(): Promise<string | null> {
140
+ // Snapshot the refresh token we intend to rotate before contending for the
141
+ // lock, so we can tell inside the lock whether a peer rotated ahead of us.
142
+ const before = loadConfig();
143
+ if (!before.oauthRefreshToken || !before.oauthClientId) return null;
144
+
145
+ return withRefreshLock(async () => {
146
+ // Re-read inside the lock: a peer may have rotated while we waited. If the
147
+ // stored refresh token changed, our snapshot is now consumed — adopt the
148
+ // peer's fresh access token instead of replaying ours (which would trip
149
+ // reuse detection and revoke the whole family).
150
+ const config = loadConfig();
151
+ if (
152
+ config.oauthRefreshToken !== before.oauthRefreshToken &&
153
+ config.oauthAccessToken
154
+ ) {
155
+ return config.oauthAccessToken;
156
+ }
157
+ if (!config.oauthRefreshToken || !config.oauthClientId) return null;
158
+
159
+ const base = oauthBaseFromApiUrl(config.apiUrl);
160
+ let res: Response;
161
+ try {
162
+ res = await fetch(`${base}/token`, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
165
+ body: new URLSearchParams({
166
+ grant_type: "refresh_token",
167
+ refresh_token: config.oauthRefreshToken,
168
+ client_id: config.oauthClientId,
169
+ }).toString(),
170
+ });
171
+ } catch {
172
+ // Network error — leave stored tokens intact so a later attempt can retry.
173
+ return null;
174
+ }
175
+
176
+ if (!res.ok) {
177
+ // Only invalid_grant (revoked / expired / rotated refresh token) is
178
+ // terminal: clear the dead OAuth credentials so the runtime stops retrying
179
+ // and surfaces a re-auth prompt. A transient 4xx (429 rate-limit, an
180
+ // intermediary 400) or 5xx must leave the stored tokens intact so a later
181
+ // attempt can retry — wiping on the broad 4xx class would force a full
182
+ // re-setup on a passing rate-limit. The token endpoint returns
183
+ // { error: "invalid_grant" } for every terminal case (oauth/index.ts).
184
+ const errorCode = await res
185
+ .json()
186
+ .then((b) => (b as { error?: string } | null)?.error ?? null)
187
+ .catch(() => null);
188
+ if (errorCode === "invalid_grant") {
189
+ saveConfig({
190
+ oauthAccessToken: null,
191
+ oauthRefreshToken: null,
192
+ oauthExpiresAt: null,
193
+ oauthClientId: null,
194
+ });
195
+ }
196
+ return null;
197
+ }
198
+
199
+ const body = (await res.json().catch(() => null)) as RefreshResponse | null;
200
+ if (!body?.access_token || !body.refresh_token) return null;
201
+
202
+ saveConfig({
203
+ oauthAccessToken: body.access_token,
204
+ oauthRefreshToken: body.refresh_token,
205
+ oauthExpiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
206
+ });
207
+ return body.access_token;
208
+ });
209
+ }
package/src/server.ts CHANGED
@@ -2287,7 +2287,7 @@ async function handleToolCall(
2287
2287
  case "harmony_delete_label": {
2288
2288
  const labelId = z.string().uuid().parse(args.labelId);
2289
2289
  const result = await client.deleteLabel(labelId);
2290
- return { success: true, ...result };
2290
+ return { ...result };
2291
2291
  }
2292
2292
 
2293
2293
  case "harmony_add_label_to_card": {
@@ -2612,7 +2612,7 @@ async function handleToolCall(
2612
2612
  language: (args.language as string) || "en-US",
2613
2613
  execute: args.execute === true,
2614
2614
  });
2615
- return { success: true, ...result };
2615
+ return { success: true, ...(result as Record<string, unknown>) };
2616
2616
  }
2617
2617
 
2618
2618
  // Agent context operations
@@ -2692,8 +2692,8 @@ async function handleToolCall(
2692
2692
  const workspaceId = deps.getActiveWorkspaceId();
2693
2693
  if (workspaceId) {
2694
2694
  const { members } = await client.getWorkspaceMembers(workspaceId);
2695
- const user = members.find(
2696
- (m: { email: string }) => m.email === userEmail,
2695
+ const user = (members as Array<{ id: string; email: string }>).find(
2696
+ (m) => m.email === userEmail,
2697
2697
  );
2698
2698
  if (user) {
2699
2699
  await client.updateCard(cardId, { assigneeId: user.id });
@@ -3815,8 +3815,8 @@ async function handleToolCall(
3815
3815
  content: summary,
3816
3816
  type: "lesson",
3817
3817
  scope: "project",
3818
- workspaceId,
3819
- projectId: plan.project_id,
3818
+ workspace_id: workspaceId,
3819
+ project_id: plan.project_id,
3820
3820
  tags: ["plan", "archived"],
3821
3821
  confidence: 0.8,
3822
3822
  });
package/src/skills.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
- import { HarmonyApiClient } from "./api-client.js";
10
+ import { getClient } from "./api-client.js";
11
11
  import { areSkillsInstalled, isConfigured } from "./config.js";
12
12
  import { loadHmyConfig } from "./hmy-config.js";
13
13
 
@@ -286,7 +286,9 @@ export async function refreshSkills(
286
286
  const status = areSkillsInstalled();
287
287
  if (!status.installed) return { updated: false };
288
288
 
289
- const client = new HarmonyApiClient();
289
+ // Shared singleton so the startup skills check inherits the refresh-on-401
290
+ // path; a bare client couldn't refresh a stale OAuth token (card #364).
291
+ const client = getClient();
290
292
  const versionInfo = await client.fetchSkillsVersion();
291
293
  // We hit the network — reset the TTL window regardless of outcome.
292
294
  recordCheck();