@gethmy/mcp 2.9.3 → 2.9.5

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.
@@ -18,8 +18,6 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
- var DEFAULT_API_URL = "https://app.gethmy.com/api";
22
- var LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
23
21
  function getConfigDir() {
24
22
  return join(homedir(), ".harmony-mcp");
25
23
  }
@@ -29,17 +27,24 @@ function getConfigPath() {
29
27
  function getLocalConfigPath(cwd) {
30
28
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
31
29
  }
30
+ function emptyConfig() {
31
+ return {
32
+ apiKey: null,
33
+ apiUrl: DEFAULT_API_URL,
34
+ activeWorkspaceId: null,
35
+ activeProjectId: null,
36
+ userEmail: null,
37
+ memoryDir: null,
38
+ oauthAccessToken: null,
39
+ oauthRefreshToken: null,
40
+ oauthExpiresAt: null,
41
+ oauthClientId: null
42
+ };
43
+ }
32
44
  function loadConfig() {
33
45
  const configPath = getConfigPath();
34
46
  if (!existsSync(configPath)) {
35
- return {
36
- apiKey: null,
37
- apiUrl: DEFAULT_API_URL,
38
- activeWorkspaceId: null,
39
- activeProjectId: null,
40
- userEmail: null,
41
- memoryDir: null
42
- };
47
+ return emptyConfig();
43
48
  }
44
49
  try {
45
50
  const data = readFileSync(configPath, "utf-8");
@@ -50,17 +55,14 @@ function loadConfig() {
50
55
  activeWorkspaceId: config.activeWorkspaceId || null,
51
56
  activeProjectId: config.activeProjectId || null,
52
57
  userEmail: config.userEmail || null,
53
- memoryDir: config.memoryDir || null
58
+ memoryDir: config.memoryDir || null,
59
+ oauthAccessToken: config.oauthAccessToken || null,
60
+ oauthRefreshToken: config.oauthRefreshToken || null,
61
+ oauthExpiresAt: typeof config.oauthExpiresAt === "number" ? config.oauthExpiresAt : null,
62
+ oauthClientId: config.oauthClientId || null
54
63
  };
55
64
  } catch {
56
- return {
57
- apiKey: null,
58
- apiUrl: DEFAULT_API_URL,
59
- activeWorkspaceId: null,
60
- activeProjectId: null,
61
- userEmail: null,
62
- memoryDir: null
63
- };
65
+ return emptyConfig();
64
66
  }
65
67
  }
66
68
  function saveConfig(config) {
@@ -108,13 +110,17 @@ function saveLocalConfig(config, cwd) {
108
110
  function hasLocalConfig(cwd) {
109
111
  return existsSync(getLocalConfigPath(cwd));
110
112
  }
111
- function getApiKey() {
113
+ function getActiveCredential() {
112
114
  const config = loadConfig();
113
- if (!config.apiKey) {
114
- throw new Error(`Not configured. Run "npx @gethmy/mcp setup" to set your API key.
115
- ` + "You can generate an API key at https://gethmy.com → Settings → API Keys.");
116
- }
117
- return config.apiKey;
115
+ if (config.oauthAccessToken)
116
+ return config.oauthAccessToken;
117
+ if (config.apiKey)
118
+ return config.apiKey;
119
+ throw new Error(`Not configured. Run "npx @gethmy/mcp setup" to connect Harmony.
120
+ ` + "Setup authorizes in your browser — no API key handling required.");
121
+ }
122
+ function getApiKey() {
123
+ return getActiveCredential();
118
124
  }
119
125
  function getApiUrl() {
120
126
  const config = loadConfig();
@@ -157,7 +163,7 @@ function getActiveProjectId(cwd) {
157
163
  }
158
164
  function isConfigured() {
159
165
  const config = loadConfig();
160
- return !!config.apiKey;
166
+ return !!(config.apiKey || config.oauthAccessToken);
161
167
  }
162
168
  function areSkillsInstalled(cwd) {
163
169
  const home = homedir();
@@ -201,6 +207,10 @@ function getMemoryDir() {
201
207
  return config.memoryDir;
202
208
  return join(homedir(), ".harmony", "memory");
203
209
  }
210
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
211
+ var init_config = () => {};
212
+ init_config();
213
+
204
214
  export {
205
215
  setUserEmail,
206
216
  setActiveWorkspace,
@@ -221,5 +231,6 @@ export {
221
231
  getApiKey,
222
232
  getActiveWorkspaceId,
223
233
  getActiveProjectId,
234
+ getActiveCredential,
224
235
  areSkillsInstalled
225
236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -63,7 +63,7 @@
63
63
  "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
64
64
  "test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
65
65
  "typecheck": "tsc --noEmit",
66
- "prepublishOnly": "bun run build"
66
+ "prepublishOnly": "bun run typecheck && bun run build"
67
67
  },
68
68
  "dependencies": {
69
69
  "@clack/prompts": "^0.11.0",
package/src/api-client.ts CHANGED
@@ -155,15 +155,21 @@ export class HarmonyApiClient {
155
155
  private apiKey: string;
156
156
  private apiUrl: string;
157
157
  private onUnauthorized?: () => void;
158
+ private refreshCredential?: () => Promise<string | null>;
158
159
 
159
160
  constructor(options?: {
160
161
  apiKey?: string;
161
162
  apiUrl?: string;
162
163
  onUnauthorized?: () => void;
164
+ // Called once on a 401 to obtain a fresh credential (OAuth refresh in stdio
165
+ // mode). Returns the new credential to retry with, or null to give up and
166
+ // surface HarmonyUnauthorizedError.
167
+ refreshCredential?: () => Promise<string | null>;
163
168
  }) {
164
169
  this.apiKey = options?.apiKey ?? getApiKey();
165
170
  this.apiUrl = options?.apiUrl ?? getApiUrl();
166
171
  this.onUnauthorized = options?.onUnauthorized;
172
+ this.refreshCredential = options?.refreshCredential;
167
173
  }
168
174
 
169
175
  getApiUrl(): string {
@@ -217,6 +223,7 @@ export class HarmonyApiClient {
217
223
  ): Promise<T> {
218
224
  const url = `${this.apiUrl}/v1${path}`;
219
225
  let lastError: Error | null = null;
226
+ let refreshed = false;
220
227
  const contentType = options?.contentType || "application/json";
221
228
  const accept = options?.accept || "application/json";
222
229
 
@@ -253,10 +260,26 @@ export class HarmonyApiClient {
253
260
  ? null
254
261
  : `API error: ${response.status} (non-JSON response)`) ||
255
262
  `API error: ${response.status}`;
256
- // 401: token rejected by harmony-api. Don't retry surface a typed
257
- // error so the MCP transport layer can issue an HTTP 401 challenge
258
- // and trigger the client's OAuth refresh flow.
263
+ // 401: credential rejected. In stdio mode we own an OAuth token, so
264
+ // try a one-shot refresh and retry with the new credential. If there
265
+ // is no refresher (remote mode) or it fails, surface a typed error so
266
+ // the transport layer can issue an HTTP 401 challenge / re-auth.
259
267
  if (response.status === 401) {
268
+ if (this.refreshCredential && !refreshed) {
269
+ refreshed = true;
270
+ const fresh = await this.refreshCredential();
271
+ if (fresh) {
272
+ this.apiKey = fresh;
273
+ // A credential refresh is a one-shot, not a network retry, so
274
+ // give back the attempt slot: a 401 on the final iteration
275
+ // (after transient errors burned the budget) must still
276
+ // re-issue with the fresh token rather than throw the stale
277
+ // error. The `refreshed` guard caps this at one extra pass — a
278
+ // second 401 falls through to the unauthorized throw.
279
+ attempt--;
280
+ continue;
281
+ }
282
+ }
260
283
  this.onUnauthorized?.();
261
284
  throw new HarmonyUnauthorizedError(errorMsg);
262
285
  }
@@ -298,6 +321,7 @@ export class HarmonyApiClient {
298
321
  ): Promise<string> {
299
322
  const url = `${this.apiUrl}/v1${path}`;
300
323
  let lastError: Error | null = null;
324
+ let refreshed = false;
301
325
  const contentType = options?.contentType || "application/json";
302
326
  const accept = options?.accept || "text/markdown";
303
327
 
@@ -323,6 +347,21 @@ export class HarmonyApiClient {
323
347
  errorMsg = text || `API error: ${response.status}`;
324
348
  }
325
349
  if (response.status === 401) {
350
+ if (this.refreshCredential && !refreshed) {
351
+ refreshed = true;
352
+ const fresh = await this.refreshCredential();
353
+ if (fresh) {
354
+ this.apiKey = fresh;
355
+ // A credential refresh is a one-shot, not a network retry, so
356
+ // give back the attempt slot: a 401 on the final iteration
357
+ // (after transient errors burned the budget) must still
358
+ // re-issue with the fresh token rather than throw the stale
359
+ // error. The `refreshed` guard caps this at one extra pass — a
360
+ // second 401 falls through to the unauthorized throw.
361
+ attempt--;
362
+ continue;
363
+ }
364
+ }
326
365
  this.onUnauthorized?.();
327
366
  throw new HarmonyUnauthorizedError(errorMsg);
328
367
  }
@@ -735,6 +774,7 @@ export class HarmonyApiClient {
735
774
  cacheCreationInputTokens?: number;
736
775
  cacheReadInputTokens?: number;
737
776
  modelName?: string;
777
+ numTurns?: number;
738
778
  recentActions?: { action: string; ts: string }[];
739
779
  failureReason?:
740
780
  | "verification"
@@ -768,6 +808,7 @@ export class HarmonyApiClient {
768
808
  cacheCreationInputTokens?: number;
769
809
  cacheReadInputTokens?: number;
770
810
  modelName?: string;
811
+ numTurns?: number;
771
812
  failureReason?:
772
813
  | "verification"
773
814
  | "review"
@@ -1660,7 +1701,14 @@ let client: HarmonyApiClient | null = null;
1660
1701
 
1661
1702
  export function getClient(): HarmonyApiClient {
1662
1703
  if (!client) {
1663
- client = new HarmonyApiClient();
1704
+ // stdio mode owns its OAuth token, so wire the refresh-on-401 path. Lazy
1705
+ // import avoids a module cycle (oauth-refresh → config, api-client → config).
1706
+ client = new HarmonyApiClient({
1707
+ refreshCredential: async () => {
1708
+ const { refreshOAuthToken } = await import("./oauth-refresh.js");
1709
+ return refreshOAuthToken();
1710
+ },
1711
+ });
1664
1712
  }
1665
1713
  return client;
1666
1714
  }
package/src/cli.ts CHANGED
@@ -59,7 +59,11 @@ program
59
59
  console.log("Status: Configured\n");
60
60
 
61
61
  console.log("API:");
62
- console.log(` Key: ${globalConfig.apiKey?.slice(0, 8)}...`);
62
+ if (globalConfig.oauthAccessToken) {
63
+ console.log(" Credential: Browser sign-in (OAuth, workspace-scoped)");
64
+ } else {
65
+ console.log(` Key: ${globalConfig.apiKey?.slice(0, 8)}...`);
66
+ }
63
67
  console.log(` URL: ${globalConfig.apiUrl}`);
64
68
  console.log(
65
69
  ` Email: ${globalConfig.userEmail ? maskEmail(globalConfig.userEmail) : "(not set)"}`,
@@ -131,6 +135,10 @@ program
131
135
  activeWorkspaceId: null,
132
136
  activeProjectId: null,
133
137
  userEmail: null,
138
+ oauthAccessToken: null,
139
+ oauthRefreshToken: null,
140
+ oauthExpiresAt: null,
141
+ oauthClientId: null,
134
142
  });
135
143
  console.log("Configuration reset successfully");
136
144
  console.log("\nTo reconfigure, run: npx @gethmy/mcp setup");
@@ -144,7 +152,10 @@ program
144
152
  "Project slug — resolves to workspace + project in one step (e.g. harmony-6590761b)",
145
153
  )
146
154
  .option("-f, --force", "Overwrite existing configuration files")
147
- .option("-k, --api-key <key>", "API key (skips prompt)")
155
+ .option(
156
+ "-k, --api-key <key>",
157
+ "DEPRECATED (insecure: key leaks via argv/shell history). For unattended CI only — interactive setup uses browser sign-in.",
158
+ )
148
159
  .option("-e, --email <email>", "Your email for auto-assignment")
149
160
  .option(
150
161
  "-a, --agents <agents...>",
package/src/config.ts CHANGED
@@ -9,6 +9,16 @@ export interface HarmonyConfig {
9
9
  activeProjectId: string | null;
10
10
  userEmail: string | null;
11
11
  memoryDir: string | null;
12
+ // OAuth 2.1 credentials (card #364). Preferred over `apiKey` when present —
13
+ // workspace-scoped + expiring, refreshed via the rotating refresh token.
14
+ // Acquired by the loopback+PKCE browser flow in `setup` (see oauth-login.ts);
15
+ // `apiKey` stays as the legacy/CI credential.
16
+ oauthAccessToken: string | null;
17
+ oauthRefreshToken: string | null;
18
+ // Epoch milliseconds at which the access token expires.
19
+ oauthExpiresAt: number | null;
20
+ // The public client_id the tokens were issued to — required to refresh them.
21
+ oauthClientId: string | null;
12
22
  }
13
23
 
14
24
  /**
@@ -35,18 +45,26 @@ export function getLocalConfigPath(cwd?: string): string {
35
45
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
36
46
  }
37
47
 
48
+ function emptyConfig(): HarmonyConfig {
49
+ return {
50
+ apiKey: null,
51
+ apiUrl: DEFAULT_API_URL,
52
+ activeWorkspaceId: null,
53
+ activeProjectId: null,
54
+ userEmail: null,
55
+ memoryDir: null,
56
+ oauthAccessToken: null,
57
+ oauthRefreshToken: null,
58
+ oauthExpiresAt: null,
59
+ oauthClientId: null,
60
+ };
61
+ }
62
+
38
63
  export function loadConfig(): HarmonyConfig {
39
64
  const configPath = getConfigPath();
40
65
 
41
66
  if (!existsSync(configPath)) {
42
- return {
43
- apiKey: null,
44
- apiUrl: DEFAULT_API_URL,
45
- activeWorkspaceId: null,
46
- activeProjectId: null,
47
- userEmail: null,
48
- memoryDir: null,
49
- };
67
+ return emptyConfig();
50
68
  }
51
69
 
52
70
  try {
@@ -59,16 +77,16 @@ export function loadConfig(): HarmonyConfig {
59
77
  activeProjectId: config.activeProjectId || null,
60
78
  userEmail: config.userEmail || null,
61
79
  memoryDir: config.memoryDir || null,
80
+ oauthAccessToken: config.oauthAccessToken || null,
81
+ oauthRefreshToken: config.oauthRefreshToken || null,
82
+ oauthExpiresAt:
83
+ typeof config.oauthExpiresAt === "number"
84
+ ? config.oauthExpiresAt
85
+ : null,
86
+ oauthClientId: config.oauthClientId || null,
62
87
  };
63
88
  } catch {
64
- return {
65
- apiKey: null,
66
- apiUrl: DEFAULT_API_URL,
67
- activeWorkspaceId: null,
68
- activeProjectId: null,
69
- userEmail: null,
70
- memoryDir: null,
71
- };
89
+ return emptyConfig();
72
90
  }
73
91
  }
74
92
 
@@ -131,15 +149,25 @@ export function hasLocalConfig(cwd?: string): boolean {
131
149
  return existsSync(getLocalConfigPath(cwd));
132
150
  }
133
151
 
134
- export function getApiKey(): string {
152
+ /**
153
+ * The credential the runtime should send as `X-API-Key`. harmony-api accepts
154
+ * both an OAuth access token (`hmy_at_`) and a legacy key (`hmy_`) on that
155
+ * header, so callers don't branch. Prefers a fresh OAuth access token, then a
156
+ * (possibly stale) OAuth access token the caller can refresh on 401, then the
157
+ * legacy `apiKey`.
158
+ */
159
+ export function getActiveCredential(): string {
135
160
  const config = loadConfig();
136
- if (!config.apiKey) {
137
- throw new Error(
138
- 'Not configured. Run "npx @gethmy/mcp setup" to set your API key.\n' +
139
- "You can generate an API key at https://gethmy.com Settings API Keys.",
140
- );
141
- }
142
- return config.apiKey;
161
+ if (config.oauthAccessToken) return config.oauthAccessToken;
162
+ if (config.apiKey) return config.apiKey;
163
+ throw new Error(
164
+ 'Not configured. Run "npx @gethmy/mcp setup" to connect Harmony.\n' +
165
+ "Setup authorizes in your browser — no API key handling required.",
166
+ );
167
+ }
168
+
169
+ export function getApiKey(): string {
170
+ return getActiveCredential();
143
171
  }
144
172
 
145
173
  export function getApiUrl(): string {
@@ -203,7 +231,7 @@ export function getActiveProjectId(cwd?: string): string | null {
203
231
 
204
232
  export function isConfigured(): boolean {
205
233
  const config = loadConfig();
206
- return !!config.apiKey;
234
+ return !!(config.apiKey || config.oauthAccessToken);
207
235
  }
208
236
 
209
237
  /**
@@ -36,8 +36,9 @@ export async function autoExpandGraph(
36
36
  limit: 20,
37
37
  });
38
38
 
39
- candidates = (entities as Array<{ id: string }>)
40
- .filter((e) => e.id !== entityId)
39
+ // Filter: exclude self, only high-enough confidence results
40
+ candidates = (entities as Array<{ id: string; confidence?: number }>)
41
+ .filter((e) => e.id !== entityId && (e.confidence ?? 1) >= 0.4)
41
42
  .slice(0, maxRelations);
42
43
 
43
44
  // Retry once after 2s if no candidates found (handles embedding generation race)
@@ -47,8 +48,10 @@ export async function autoExpandGraph(
47
48
  project_id: projectId,
48
49
  limit: 20,
49
50
  });
50
- candidates = (retry.entities as Array<{ id: string }>)
51
- .filter((e) => e.id !== entityId)
51
+ candidates = (
52
+ retry.entities as Array<{ id: string; confidence?: number }>
53
+ )
54
+ .filter((e) => e.id !== entityId && (e.confidence ?? 1) >= 0.4)
52
55
  .slice(0, maxRelations);
53
56
  }
54
57
 
package/src/http.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { serve } from "bun";
14
- import { Hono } from "hono";
14
+ import { type Context, Hono } from "hono";
15
15
  import { cors } from "hono/cors";
16
16
  import { loadConfig } from "./config.js";
17
17
 
@@ -122,18 +122,7 @@ app.get("/", (c) =>
122
122
  );
123
123
 
124
124
  // Generic proxy handler
125
- async function handleRequest(
126
- c: {
127
- req: {
128
- method: string;
129
- url: string;
130
- header: (name: string) => string | undefined;
131
- };
132
- json: () => Promise<unknown>;
133
- },
134
- method: string,
135
- path: string,
136
- ) {
125
+ async function handleRequest(c: Context, method: string, path: string) {
137
126
  try {
138
127
  const authHeader = c.req.header("Authorization");
139
128
  const apiKey = c.req.header("X-API-Key");
@@ -141,7 +130,7 @@ async function handleRequest(
141
130
  let body: unknown;
142
131
  if (["POST", "PATCH", "PUT"].includes(method)) {
143
132
  try {
144
- body = await c.json();
133
+ body = await c.req.json();
145
134
  } catch {
146
135
  // No body or invalid JSON
147
136
  }
@@ -55,6 +55,7 @@ export type FloorRuleId =
55
55
  | "frequency-meta"
56
56
  | "self-referential"
57
57
  | "bare-type-prefix"
58
+ | "impl-detail-lesson"
58
59
  | "specificity-floor"
59
60
  | "length-floor"
60
61
  | "operational-data-ban";
@@ -218,7 +219,7 @@ export function validateMemoryQuality(
218
219
  rule: "impl-detail-lesson",
219
220
  message:
220
221
  "Lesson body reads as an implementation TODO/issue, not a learning. Convert to a positive takeaway or move to a code comment.",
221
- } as FloorRejection;
222
+ };
222
223
  }
223
224
 
224
225
  // Rule 4: Specificity floor (skipped for doc-source imports — they may