@gethmy/mcp 2.17.1 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1869,6 +1869,10 @@ class HarmonyApiClient {
1869
1869
  async getCardByShortId(projectId, shortId) {
1870
1870
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1871
1871
  }
1872
+ async resolveCardByShortId(shortId, preferredProjectId) {
1873
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1874
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1875
+ }
1872
1876
  async bulkGetCards(projectId, shortIds) {
1873
1877
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1874
1878
  shortIds
@@ -5763,9 +5767,51 @@ async function handleToolCall(name, args, deps) {
5763
5767
  }
5764
5768
  if (hasShortId) {
5765
5769
  const shortId = z.number().int().positive().parse(args.shortId);
5766
- const projectId = args.projectId || getProjectId();
5767
- const result2 = await client3.getCardByShortId(projectId, shortId);
5768
- return { success: true, ...result2 };
5770
+ const explicitProjectId = args.projectId;
5771
+ if (explicitProjectId) {
5772
+ const result2 = await client3.getCardByShortId(explicitProjectId, shortId);
5773
+ return { success: true, ...result2 };
5774
+ }
5775
+ const activeProjectId = deps.getActiveProjectId();
5776
+ const resolved = await client3.resolveCardByShortId(shortId, activeProjectId);
5777
+ if (resolved.kind === "found") {
5778
+ const cardTitle = resolved.card?.title ?? `#${shortId}`;
5779
+ const where = resolved.project.workspaceName ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")` : `project "${resolved.project.name ?? resolved.project.id}"`;
5780
+ const established = activeProjectId == null;
5781
+ if (established) {
5782
+ deps.setActiveProject(resolved.project.id);
5783
+ }
5784
+ return {
5785
+ success: true,
5786
+ card: resolved.card,
5787
+ resolvedProject: resolved.project,
5788
+ activeProjectId: resolved.project.id,
5789
+ note: established ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.` : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`
5790
+ };
5791
+ }
5792
+ if (resolved.kind === "not_in_preferred") {
5793
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5794
+ `);
5795
+ throw new Error(`#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` + `It exists in ${resolved.candidates.length} other project(s) you can access:
5796
+ ${list}
5797
+
5798
+ ` + `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`);
5799
+ }
5800
+ if (resolved.kind === "ambiguous") {
5801
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5802
+ `);
5803
+ return {
5804
+ success: true,
5805
+ needsDisambiguation: true,
5806
+ shortId,
5807
+ candidates: resolved.candidates,
5808
+ message: `#${shortId} exists in ${resolved.candidates.length} projects you can access:
5809
+ ${list}
5810
+
5811
+ ` + `Ask which one is meant, then re-fetch with an explicit projectId ` + `(or call harmony_set_project_context first).`
5812
+ };
5813
+ }
5814
+ throw new Error(resolved.searchedProjectCount === 0 ? `#${shortId} can't be resolved: no project is accessible to this connection. ` + `Check the workspace this connection is authorized for with harmony_list_workspaces.` : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` + `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` + `Use harmony_list_projects to see them, or pass an explicit projectId.`);
5769
5815
  }
5770
5816
  const cardId = z.string().uuid().parse(args.cardId);
5771
5817
  const result = await client3.getCard(cardId);
package/dist/index.js CHANGED
@@ -1864,6 +1864,10 @@ class HarmonyApiClient {
1864
1864
  async getCardByShortId(projectId, shortId) {
1865
1865
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1866
1866
  }
1867
+ async resolveCardByShortId(shortId, preferredProjectId) {
1868
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1869
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1870
+ }
1867
1871
  async bulkGetCards(projectId, shortIds) {
1868
1872
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1869
1873
  shortIds
@@ -5758,9 +5762,51 @@ async function handleToolCall(name, args, deps) {
5758
5762
  }
5759
5763
  if (hasShortId) {
5760
5764
  const shortId = z.number().int().positive().parse(args.shortId);
5761
- const projectId = args.projectId || getProjectId();
5762
- const result2 = await client3.getCardByShortId(projectId, shortId);
5763
- return { success: true, ...result2 };
5765
+ const explicitProjectId = args.projectId;
5766
+ if (explicitProjectId) {
5767
+ const result2 = await client3.getCardByShortId(explicitProjectId, shortId);
5768
+ return { success: true, ...result2 };
5769
+ }
5770
+ const activeProjectId = deps.getActiveProjectId();
5771
+ const resolved = await client3.resolveCardByShortId(shortId, activeProjectId);
5772
+ if (resolved.kind === "found") {
5773
+ const cardTitle = resolved.card?.title ?? `#${shortId}`;
5774
+ const where = resolved.project.workspaceName ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")` : `project "${resolved.project.name ?? resolved.project.id}"`;
5775
+ const established = activeProjectId == null;
5776
+ if (established) {
5777
+ deps.setActiveProject(resolved.project.id);
5778
+ }
5779
+ return {
5780
+ success: true,
5781
+ card: resolved.card,
5782
+ resolvedProject: resolved.project,
5783
+ activeProjectId: resolved.project.id,
5784
+ note: established ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.` : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`
5785
+ };
5786
+ }
5787
+ if (resolved.kind === "not_in_preferred") {
5788
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5789
+ `);
5790
+ throw new Error(`#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` + `It exists in ${resolved.candidates.length} other project(s) you can access:
5791
+ ${list}
5792
+
5793
+ ` + `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`);
5794
+ }
5795
+ if (resolved.kind === "ambiguous") {
5796
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5797
+ `);
5798
+ return {
5799
+ success: true,
5800
+ needsDisambiguation: true,
5801
+ shortId,
5802
+ candidates: resolved.candidates,
5803
+ message: `#${shortId} exists in ${resolved.candidates.length} projects you can access:
5804
+ ${list}
5805
+
5806
+ ` + `Ask which one is meant, then re-fetch with an explicit projectId ` + `(or call harmony_set_project_context first).`
5807
+ };
5808
+ }
5809
+ throw new Error(resolved.searchedProjectCount === 0 ? `#${shortId} can't be resolved: no project is accessible to this connection. ` + `Check the workspace this connection is authorized for with harmony_list_workspaces.` : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` + `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` + `Use harmony_list_projects to see them, or pass an explicit projectId.`);
5764
5810
  }
5765
5811
  const cardId = z.string().uuid().parse(args.cardId);
5766
5812
  const result = await client3.getCard(cardId);
@@ -1316,6 +1316,10 @@ class HarmonyApiClient {
1316
1316
  async getCardByShortId(projectId, shortId) {
1317
1317
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1318
1318
  }
1319
+ async resolveCardByShortId(shortId, preferredProjectId) {
1320
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1321
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1322
+ }
1319
1323
  async bulkGetCards(projectId, shortIds) {
1320
1324
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1321
1325
  shortIds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.17.1",
3
+ "version": "2.18.0",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -164,6 +164,46 @@ export interface CardExternalLinkRow {
164
164
  created_at: string;
165
165
  }
166
166
 
167
+ /** One candidate card returned by the cross-project short-id resolver (#709). */
168
+ export interface ResolveCardCandidate {
169
+ cardId: string;
170
+ title: string;
171
+ projectId: string;
172
+ projectName: string | null;
173
+ workspaceId: string;
174
+ workspaceName: string | null;
175
+ }
176
+
177
+ /** Discriminated result of GET /cards/resolve/:shortId (#709). `found` carries
178
+ * the hydrated card + its project (for sticky-context + confirm-back);
179
+ * `ambiguous` carries the candidates so the caller asks which one; `not_found`
180
+ * carries the searched scope for a legible failure; `not_in_preferred` (#428)
181
+ * means a deliberately-set active project (the hard scope) doesn't hold the id,
182
+ * and carries the projects that DO so the caller can switch or pass an explicit
183
+ * projectId — the tool never silently hops to another project. */
184
+ export type ResolveCardApiResult =
185
+ | {
186
+ kind: "found";
187
+ card: unknown;
188
+ project: {
189
+ id: string;
190
+ name: string | null;
191
+ workspaceId: string;
192
+ workspaceName: string | null;
193
+ };
194
+ }
195
+ | { kind: "ambiguous"; candidates: ResolveCardCandidate[] }
196
+ | {
197
+ kind: "not_found";
198
+ searchedWorkspaceCount: number;
199
+ searchedProjectCount: number;
200
+ }
201
+ | {
202
+ kind: "not_in_preferred";
203
+ preferredProjectId: string;
204
+ candidates: ResolveCardCandidate[];
205
+ };
206
+
167
207
  /** Result of the classify-card classifier (card #415). Any field may be null
168
208
  * if the LLM didn't return a usable value. `model_override` is never touched. */
169
209
  export interface CardClassificationResult {
@@ -694,6 +734,21 @@ export class HarmonyApiClient {
694
734
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
695
735
  }
696
736
 
737
+ // #709: resolve a `#shortId` across every project the caller can reach when no
738
+ // explicit project is in play (the remote/OAuth MCP seeds a workspace but no
739
+ // project). `preferredProjectId` biases to the session's active/sticky project
740
+ // so a deliberately-set context wins outright instead of reading as ambiguous.
741
+ // Always resolves (HTTP 200) with a discriminated body — the caller decides.
742
+ async resolveCardByShortId(
743
+ shortId: number,
744
+ preferredProjectId?: string | null,
745
+ ): Promise<ResolveCardApiResult> {
746
+ const qs = preferredProjectId
747
+ ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}`
748
+ : "";
749
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
750
+ }
751
+
697
752
  async bulkGetCards(
698
753
  projectId: string,
699
754
  shortIds: number[],
package/src/server.ts CHANGED
@@ -2872,9 +2872,113 @@ async function handleToolCall(
2872
2872
  }
2873
2873
  if (hasShortId) {
2874
2874
  const shortId = z.number().int().positive().parse(args.shortId);
2875
- const projectId = (args.projectId as string) || getProjectId();
2876
- const result = await client.getCardByShortId(projectId, shortId);
2877
- return { success: true, ...result };
2875
+ const explicitProjectId = args.projectId as string | undefined;
2876
+
2877
+ // Explicit projectId is the deterministic override: fetch from exactly
2878
+ // that project and error if it's not there — never look elsewhere.
2879
+ if (explicitProjectId) {
2880
+ const result = await client.getCardByShortId(
2881
+ explicitProjectId,
2882
+ shortId,
2883
+ );
2884
+ return { success: true, ...result };
2885
+ }
2886
+
2887
+ // No explicit project: resolve the `#shortId` (#709). The active/sticky
2888
+ // project is a HARD SCOPE (#428) — when one is set, the resolver only
2889
+ // matches within it (a miss comes back as `not_in_preferred`, handled
2890
+ // below) and this read never repoints it. Cross-project auto-resolution
2891
+ // + sticky happens ONLY when no active project is set — the remote/OAuth
2892
+ // MCP case this feature exists for, where the session seeds a workspace
2893
+ // but no project.
2894
+ const activeProjectId = deps.getActiveProjectId();
2895
+ const resolved = await client.resolveCardByShortId(
2896
+ shortId,
2897
+ activeProjectId,
2898
+ );
2899
+
2900
+ if (resolved.kind === "found") {
2901
+ const cardTitle =
2902
+ (resolved.card as { title?: string } | null)?.title ??
2903
+ `#${shortId}`;
2904
+ const where = resolved.project.workspaceName
2905
+ ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")`
2906
+ : `project "${resolved.project.name ?? resolved.project.id}"`;
2907
+ // Sticky ONLY when this ESTABLISHES a context (none was set). A
2908
+ // deliberately-set active project is a hard scope: `found` there means
2909
+ // the card was already in it, so there is nothing to change — and we
2910
+ // never let a read silently repoint a context the user chose (which,
2911
+ // on the local stdio MCP, persists to ~/.harmony-mcp/config.json
2912
+ // across sessions). Confirm the target back either way so a
2913
+ // wrong-context resolve is visible immediately.
2914
+ const established = activeProjectId == null;
2915
+ if (established) {
2916
+ deps.setActiveProject(resolved.project.id);
2917
+ }
2918
+ return {
2919
+ success: true,
2920
+ card: resolved.card,
2921
+ resolvedProject: resolved.project,
2922
+ activeProjectId: resolved.project.id,
2923
+ note: established
2924
+ ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.`
2925
+ : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`,
2926
+ };
2927
+ }
2928
+
2929
+ if (resolved.kind === "not_in_preferred") {
2930
+ // Hard scope (#428): the active project is a constraint the user set,
2931
+ // so a `#shortId` that isn't in it is an error — NOT a silent hop to
2932
+ // whatever other project happens to carry that number, and NOT a
2933
+ // change to the active project. Name where it *does* live so the
2934
+ // caller can switch context or fetch it explicitly.
2935
+ const list = resolved.candidates
2936
+ .map(
2937
+ (c) =>
2938
+ ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${
2939
+ c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""
2940
+ } (projectId: ${c.projectId})`,
2941
+ )
2942
+ .join("\n");
2943
+ throw new Error(
2944
+ `#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` +
2945
+ `It exists in ${resolved.candidates.length} other project(s) you can access:\n${list}\n\n` +
2946
+ `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`,
2947
+ );
2948
+ }
2949
+
2950
+ if (resolved.kind === "ambiguous") {
2951
+ // Never guess: hand the candidates back so the caller can disambiguate.
2952
+ const list = resolved.candidates
2953
+ .map(
2954
+ (c) =>
2955
+ ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${
2956
+ c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""
2957
+ } (projectId: ${c.projectId})`,
2958
+ )
2959
+ .join("\n");
2960
+ return {
2961
+ success: true,
2962
+ needsDisambiguation: true,
2963
+ shortId,
2964
+ candidates: resolved.candidates,
2965
+ message:
2966
+ `#${shortId} exists in ${resolved.candidates.length} projects you can access:\n${list}\n\n` +
2967
+ `Ask which one is meant, then re-fetch with an explicit projectId ` +
2968
+ `(or call harmony_set_project_context first).`,
2969
+ };
2970
+ }
2971
+
2972
+ // not_found — name the searched scope instead of the bare "No project
2973
+ // specified", and point at the tools that list the caller's options.
2974
+ throw new Error(
2975
+ resolved.searchedProjectCount === 0
2976
+ ? `#${shortId} can't be resolved: no project is accessible to this connection. ` +
2977
+ `Check the workspace this connection is authorized for with harmony_list_workspaces.`
2978
+ : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` +
2979
+ `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` +
2980
+ `Use harmony_list_projects to see them, or pass an explicit projectId.`,
2981
+ );
2878
2982
  }
2879
2983
  const cardId = z.string().uuid().parse(args.cardId);
2880
2984
  const result = await client.getCard(cardId);