@gethmy/mcp 2.17.0 → 2.17.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/dist/cli.js +46 -2
- package/dist/index.js +46 -2
- package/dist/lib/api-client.js +18 -0
- package/package.json +1 -1
- package/src/api-client.ts +29 -1
- package/src/auto-session.ts +54 -1
- package/src/server.ts +36 -0
package/dist/cli.js
CHANGED
|
@@ -1499,6 +1499,21 @@ var TIMINGS = {
|
|
|
1499
1499
|
QUERY_STALE_TIME: 1000 * 60 * 5,
|
|
1500
1500
|
QUERY_GC_TIME: 1000 * 60 * 60 * 24
|
|
1501
1501
|
};
|
|
1502
|
+
// ../harmony-shared/dist/playbookStage.js
|
|
1503
|
+
var STAGE_DAEMON_OWNED_TOOLS = [
|
|
1504
|
+
"mcp__harmony__harmony_end_agent_session",
|
|
1505
|
+
"mcp__harmony__harmony_start_agent_session",
|
|
1506
|
+
"mcp__harmony__harmony_move_card"
|
|
1507
|
+
];
|
|
1508
|
+
// ../harmony-shared/dist/reviewTools.js
|
|
1509
|
+
var REVIEW_DISALLOWED_TOOLS = [
|
|
1510
|
+
...STAGE_DAEMON_OWNED_TOOLS,
|
|
1511
|
+
"mcp__harmony__harmony_update_card",
|
|
1512
|
+
"mcp__harmony__harmony_create_subtask",
|
|
1513
|
+
"mcp__harmony__harmony_update_subtask",
|
|
1514
|
+
"mcp__harmony__harmony_delete_subtask",
|
|
1515
|
+
"mcp__harmony__harmony_toggle_subtask"
|
|
1516
|
+
];
|
|
1502
1517
|
// ../harmony-shared/dist/stageHandoff.js
|
|
1503
1518
|
var HANDOFF_MARKER = "harmony:stage-handoff";
|
|
1504
1519
|
var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
|
|
@@ -1761,6 +1776,9 @@ class HarmonyApiClient {
|
|
|
1761
1776
|
async getWorkspaceMembers(workspaceId) {
|
|
1762
1777
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1763
1778
|
}
|
|
1779
|
+
async getAuthContext() {
|
|
1780
|
+
return this.request("GET", "/auth/context");
|
|
1781
|
+
}
|
|
1764
1782
|
async listWorkspaceAgents(workspaceId) {
|
|
1765
1783
|
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1766
1784
|
}
|
|
@@ -2481,6 +2499,7 @@ var AUTO_START_TRIGGERS = new Set([
|
|
|
2481
2499
|
]);
|
|
2482
2500
|
var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
2483
2501
|
var CHECK_INTERVAL_MS = 60 * 1000;
|
|
2502
|
+
var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
|
|
2484
2503
|
var DEFAULT_SCOPE = "__default__";
|
|
2485
2504
|
var scopes = new Map;
|
|
2486
2505
|
var inactivityTimer = null;
|
|
@@ -2618,17 +2637,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
|
|
|
2618
2637
|
session.status = status;
|
|
2619
2638
|
}
|
|
2620
2639
|
function heartbeatActiveSessions() {
|
|
2640
|
+
const now = Date.now();
|
|
2621
2641
|
for (const scope of scopes.values()) {
|
|
2622
2642
|
const client3 = scope.clientGetter?.();
|
|
2623
2643
|
if (!client3)
|
|
2624
2644
|
continue;
|
|
2625
|
-
for (const session of scope.sessions.
|
|
2645
|
+
for (const [cardId, session] of [...scope.sessions.entries()]) {
|
|
2626
2646
|
if ((session.status ?? "working") !== "working")
|
|
2627
2647
|
continue;
|
|
2648
|
+
if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
|
|
2649
|
+
continue;
|
|
2628
2650
|
client3.updateAgentProgress(session.cardId, {
|
|
2629
2651
|
agentIdentifier: session.agentIdentifier,
|
|
2630
2652
|
agentName: session.agentName,
|
|
2631
|
-
status: "working"
|
|
2653
|
+
status: "working",
|
|
2654
|
+
noCreate: true
|
|
2655
|
+
}).then((res) => {
|
|
2656
|
+
if (res?.session === null && scope.sessions.get(cardId) === session) {
|
|
2657
|
+
scope.sessions.delete(cardId);
|
|
2658
|
+
}
|
|
2632
2659
|
}).catch(() => {});
|
|
2633
2660
|
}
|
|
2634
2661
|
}
|
|
@@ -6148,6 +6175,23 @@ async function handleToolCall(name, args, deps) {
|
|
|
6148
6175
|
}
|
|
6149
6176
|
case "harmony_set_workspace_context": {
|
|
6150
6177
|
const workspaceId = z.string().uuid().parse(args.workspaceId);
|
|
6178
|
+
const { workspaces } = await client3.listWorkspaces();
|
|
6179
|
+
const available = workspaces ?? [];
|
|
6180
|
+
const match = available.find((w) => w?.id === workspaceId);
|
|
6181
|
+
if (!match) {
|
|
6182
|
+
const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
|
|
6183
|
+
`);
|
|
6184
|
+
throw new Error(`Workspace ${workspaceId} is not available to this connection.
|
|
6185
|
+
|
|
6186
|
+
` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
|
|
6187
|
+
|
|
6188
|
+
` + (options ? `Available here:
|
|
6189
|
+
${options}
|
|
6190
|
+
|
|
6191
|
+
` : `No workspaces are available to this connection.
|
|
6192
|
+
|
|
6193
|
+
`) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
|
|
6194
|
+
}
|
|
6151
6195
|
deps.setActiveWorkspace(workspaceId);
|
|
6152
6196
|
return { success: true, activeWorkspaceId: workspaceId };
|
|
6153
6197
|
}
|
package/dist/index.js
CHANGED
|
@@ -1494,6 +1494,21 @@ var TIMINGS = {
|
|
|
1494
1494
|
QUERY_STALE_TIME: 1000 * 60 * 5,
|
|
1495
1495
|
QUERY_GC_TIME: 1000 * 60 * 60 * 24
|
|
1496
1496
|
};
|
|
1497
|
+
// ../harmony-shared/dist/playbookStage.js
|
|
1498
|
+
var STAGE_DAEMON_OWNED_TOOLS = [
|
|
1499
|
+
"mcp__harmony__harmony_end_agent_session",
|
|
1500
|
+
"mcp__harmony__harmony_start_agent_session",
|
|
1501
|
+
"mcp__harmony__harmony_move_card"
|
|
1502
|
+
];
|
|
1503
|
+
// ../harmony-shared/dist/reviewTools.js
|
|
1504
|
+
var REVIEW_DISALLOWED_TOOLS = [
|
|
1505
|
+
...STAGE_DAEMON_OWNED_TOOLS,
|
|
1506
|
+
"mcp__harmony__harmony_update_card",
|
|
1507
|
+
"mcp__harmony__harmony_create_subtask",
|
|
1508
|
+
"mcp__harmony__harmony_update_subtask",
|
|
1509
|
+
"mcp__harmony__harmony_delete_subtask",
|
|
1510
|
+
"mcp__harmony__harmony_toggle_subtask"
|
|
1511
|
+
];
|
|
1497
1512
|
// ../harmony-shared/dist/stageHandoff.js
|
|
1498
1513
|
var HANDOFF_MARKER = "harmony:stage-handoff";
|
|
1499
1514
|
var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
|
|
@@ -1756,6 +1771,9 @@ class HarmonyApiClient {
|
|
|
1756
1771
|
async getWorkspaceMembers(workspaceId) {
|
|
1757
1772
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1758
1773
|
}
|
|
1774
|
+
async getAuthContext() {
|
|
1775
|
+
return this.request("GET", "/auth/context");
|
|
1776
|
+
}
|
|
1759
1777
|
async listWorkspaceAgents(workspaceId) {
|
|
1760
1778
|
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1761
1779
|
}
|
|
@@ -2476,6 +2494,7 @@ var AUTO_START_TRIGGERS = new Set([
|
|
|
2476
2494
|
]);
|
|
2477
2495
|
var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
2478
2496
|
var CHECK_INTERVAL_MS = 60 * 1000;
|
|
2497
|
+
var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
|
|
2479
2498
|
var DEFAULT_SCOPE = "__default__";
|
|
2480
2499
|
var scopes = new Map;
|
|
2481
2500
|
var inactivityTimer = null;
|
|
@@ -2613,17 +2632,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
|
|
|
2613
2632
|
session.status = status;
|
|
2614
2633
|
}
|
|
2615
2634
|
function heartbeatActiveSessions() {
|
|
2635
|
+
const now = Date.now();
|
|
2616
2636
|
for (const scope of scopes.values()) {
|
|
2617
2637
|
const client3 = scope.clientGetter?.();
|
|
2618
2638
|
if (!client3)
|
|
2619
2639
|
continue;
|
|
2620
|
-
for (const session of scope.sessions.
|
|
2640
|
+
for (const [cardId, session] of [...scope.sessions.entries()]) {
|
|
2621
2641
|
if ((session.status ?? "working") !== "working")
|
|
2622
2642
|
continue;
|
|
2643
|
+
if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
|
|
2644
|
+
continue;
|
|
2623
2645
|
client3.updateAgentProgress(session.cardId, {
|
|
2624
2646
|
agentIdentifier: session.agentIdentifier,
|
|
2625
2647
|
agentName: session.agentName,
|
|
2626
|
-
status: "working"
|
|
2648
|
+
status: "working",
|
|
2649
|
+
noCreate: true
|
|
2650
|
+
}).then((res) => {
|
|
2651
|
+
if (res?.session === null && scope.sessions.get(cardId) === session) {
|
|
2652
|
+
scope.sessions.delete(cardId);
|
|
2653
|
+
}
|
|
2627
2654
|
}).catch(() => {});
|
|
2628
2655
|
}
|
|
2629
2656
|
}
|
|
@@ -6143,6 +6170,23 @@ async function handleToolCall(name, args, deps) {
|
|
|
6143
6170
|
}
|
|
6144
6171
|
case "harmony_set_workspace_context": {
|
|
6145
6172
|
const workspaceId = z.string().uuid().parse(args.workspaceId);
|
|
6173
|
+
const { workspaces } = await client3.listWorkspaces();
|
|
6174
|
+
const available = workspaces ?? [];
|
|
6175
|
+
const match = available.find((w) => w?.id === workspaceId);
|
|
6176
|
+
if (!match) {
|
|
6177
|
+
const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
|
|
6178
|
+
`);
|
|
6179
|
+
throw new Error(`Workspace ${workspaceId} is not available to this connection.
|
|
6180
|
+
|
|
6181
|
+
` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
|
|
6182
|
+
|
|
6183
|
+
` + (options ? `Available here:
|
|
6184
|
+
${options}
|
|
6185
|
+
|
|
6186
|
+
` : `No workspaces are available to this connection.
|
|
6187
|
+
|
|
6188
|
+
`) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
|
|
6189
|
+
}
|
|
6146
6190
|
deps.setActiveWorkspace(workspaceId);
|
|
6147
6191
|
return { success: true, activeWorkspaceId: workspaceId };
|
|
6148
6192
|
}
|
package/dist/lib/api-client.js
CHANGED
|
@@ -946,6 +946,21 @@ var TIMINGS = {
|
|
|
946
946
|
QUERY_STALE_TIME: 1000 * 60 * 5,
|
|
947
947
|
QUERY_GC_TIME: 1000 * 60 * 60 * 24
|
|
948
948
|
};
|
|
949
|
+
// ../harmony-shared/dist/playbookStage.js
|
|
950
|
+
var STAGE_DAEMON_OWNED_TOOLS = [
|
|
951
|
+
"mcp__harmony__harmony_end_agent_session",
|
|
952
|
+
"mcp__harmony__harmony_start_agent_session",
|
|
953
|
+
"mcp__harmony__harmony_move_card"
|
|
954
|
+
];
|
|
955
|
+
// ../harmony-shared/dist/reviewTools.js
|
|
956
|
+
var REVIEW_DISALLOWED_TOOLS = [
|
|
957
|
+
...STAGE_DAEMON_OWNED_TOOLS,
|
|
958
|
+
"mcp__harmony__harmony_update_card",
|
|
959
|
+
"mcp__harmony__harmony_create_subtask",
|
|
960
|
+
"mcp__harmony__harmony_update_subtask",
|
|
961
|
+
"mcp__harmony__harmony_delete_subtask",
|
|
962
|
+
"mcp__harmony__harmony_toggle_subtask"
|
|
963
|
+
];
|
|
949
964
|
// ../harmony-shared/dist/stageHandoff.js
|
|
950
965
|
var HANDOFF_MARKER = "harmony:stage-handoff";
|
|
951
966
|
var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
|
|
@@ -1208,6 +1223,9 @@ class HarmonyApiClient {
|
|
|
1208
1223
|
async getWorkspaceMembers(workspaceId) {
|
|
1209
1224
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1210
1225
|
}
|
|
1226
|
+
async getAuthContext() {
|
|
1227
|
+
return this.request("GET", "/auth/context");
|
|
1228
|
+
}
|
|
1211
1229
|
async listWorkspaceAgents(workspaceId) {
|
|
1212
1230
|
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1213
1231
|
}
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -461,6 +461,22 @@ export class HarmonyApiClient {
|
|
|
461
461
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The auth user THIS client's credential resolves to server-side — the id
|
|
466
|
+
* harmony-api stamps onto sessions it creates for this caller. The agent
|
|
467
|
+
* daemon uses it to assert its configured `userEmail` and its API key are the
|
|
468
|
+
* same user; a divergence would make it fail closed on its own comment
|
|
469
|
+
* artifacts (card #693). `GET /v1/auth/context` is long-deployed (the MCP
|
|
470
|
+
* transport authenticates every request through it).
|
|
471
|
+
*/
|
|
472
|
+
async getAuthContext(): Promise<{
|
|
473
|
+
userId: string;
|
|
474
|
+
source: "api_key" | "oauth" | "jwt";
|
|
475
|
+
workspaceId: string | null;
|
|
476
|
+
}> {
|
|
477
|
+
return this.request("GET", "/auth/context");
|
|
478
|
+
}
|
|
479
|
+
|
|
464
480
|
async listWorkspaceAgents(
|
|
465
481
|
workspaceId: string,
|
|
466
482
|
): Promise<{ agents: WorkspaceAgent[] }> {
|
|
@@ -1067,6 +1083,12 @@ export class HarmonyApiClient {
|
|
|
1067
1083
|
recoveryBranch?: string;
|
|
1068
1084
|
attemptNumber?: number;
|
|
1069
1085
|
maxAttempts?: number;
|
|
1086
|
+
/**
|
|
1087
|
+
* Update-only: never create a session if none is live, answer
|
|
1088
|
+
* `{session: null}` instead. Set by the auto-session heartbeat, which must
|
|
1089
|
+
* report on a session but must never bring one back from the dead (#696).
|
|
1090
|
+
*/
|
|
1091
|
+
noCreate?: boolean;
|
|
1070
1092
|
},
|
|
1071
1093
|
): Promise<{ session: unknown; created: boolean }> {
|
|
1072
1094
|
return this.request("POST", `/cards/${cardId}/agent-context`, data);
|
|
@@ -1075,7 +1097,13 @@ export class HarmonyApiClient {
|
|
|
1075
1097
|
async endAgentSession(
|
|
1076
1098
|
cardId: string,
|
|
1077
1099
|
data?: {
|
|
1078
|
-
|
|
1100
|
+
/**
|
|
1101
|
+
* `cancelled` marks a HUMAN stop specifically — it is what arms the
|
|
1102
|
+
* server's human-stop cooldown (card #663). Never use it for a graceful
|
|
1103
|
+
* shutdown or an internal abort, which must stay `paused`/`failed` so the
|
|
1104
|
+
* card can be re-picked immediately (card #696).
|
|
1105
|
+
*/
|
|
1106
|
+
status?: "completed" | "paused" | "failed" | "cancelled";
|
|
1079
1107
|
progressPercent?: number;
|
|
1080
1108
|
costCents?: number;
|
|
1081
1109
|
inputTokens?: number;
|
package/src/auto-session.ts
CHANGED
|
@@ -116,6 +116,27 @@ export const AUTO_START_TRIGGERS = new Set([
|
|
|
116
116
|
export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
117
117
|
const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* How long after the agent's last harmony tool call we keep heartbeating a
|
|
121
|
+
* session (card #696).
|
|
122
|
+
*
|
|
123
|
+
* WHY: the heartbeat asserts "this agent is still working". An *explicit*
|
|
124
|
+
* session (`/hmy`) is exempt from `checkInactivity`, so an unbounded heartbeat
|
|
125
|
+
* asserted that forever — bumping `updated_at` every 60s and starving the
|
|
126
|
+
* 30-minute `cleanup_stale_agent_sessions` cron, which is the only backstop for
|
|
127
|
+
* a session whose agent finished (card moved to Review) but never called
|
|
128
|
+
* `harmony_end_agent_session`. The session then showed "working" forever.
|
|
129
|
+
*
|
|
130
|
+
* Bounding the heartbeat by real activity restores that backstop: once the
|
|
131
|
+
* agent stops calling harmony tools we stop beating, `updated_at` goes stale,
|
|
132
|
+
* and the cron closes the row as `failed`/`stale`. Sits deliberately BELOW the
|
|
133
|
+
* cron's 30-minute threshold so a stalled session can actually reach it, and
|
|
134
|
+
* well above the milestone-gap the heartbeat exists to bridge (#608). Activity
|
|
135
|
+
* revives it — `trackActivity` refreshes `lastActivityAt` on every card-scoped
|
|
136
|
+
* tool call.
|
|
137
|
+
*/
|
|
138
|
+
export const HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000; // 25 minutes
|
|
139
|
+
|
|
119
140
|
/**
|
|
120
141
|
* Scope used when no `scopeId` is supplied. stdio is single-user, so it shares
|
|
121
142
|
* one scope; the unit tests also exercise this default scope exclusively.
|
|
@@ -429,22 +450,54 @@ export function noteSessionStatus(
|
|
|
429
450
|
* `updated_at`. Covers both auto-started and explicit sessions (both live in
|
|
430
451
|
* `scope.sessions`).
|
|
431
452
|
*
|
|
453
|
+
* Two bounds keep a heartbeat from outliving the work it reports on (card #696):
|
|
454
|
+
*
|
|
455
|
+
* 1. `noCreate` — a heartbeat may only ever UPDATE a live session, never create
|
|
456
|
+
* one. The endpoint shares a single upsert with `start_agent_session`, so
|
|
457
|
+
* without this a heartbeat for a session ended out-of-band (the UI "Stop",
|
|
458
|
+
* which cannot reach this process's map) re-created the row and re-added the
|
|
459
|
+
* `agent` label. The #663 human-stop cooldown only deferred that by 10
|
|
460
|
+
* minutes, so a stopped card reliably respawned exactly one cooldown later.
|
|
461
|
+
* `session: null` back means the session is gone → untrack, stop beating.
|
|
462
|
+
* 2. `HEARTBEAT_ACTIVITY_WINDOW_MS` — only beat while the agent is still calling
|
|
463
|
+
* harmony tools, so an abandoned session goes stale and the cron can close it.
|
|
464
|
+
*
|
|
432
465
|
* No double-heartbeat for the daemon: `packages/harmony-agent` has its own
|
|
433
466
|
* progress-tracker heartbeat and never routes through mcp-server auto-sessions.
|
|
434
467
|
*/
|
|
435
468
|
export function heartbeatActiveSessions(): void {
|
|
469
|
+
const now = Date.now();
|
|
436
470
|
for (const scope of scopes.values()) {
|
|
437
471
|
const client = scope.clientGetter?.();
|
|
438
472
|
if (!client) continue;
|
|
439
|
-
|
|
473
|
+
// Snapshot: the untrack-on-ended path below mutates the map.
|
|
474
|
+
for (const [cardId, session] of [...scope.sessions.entries()]) {
|
|
440
475
|
// Default-undefined status is treated as `working` (sessions created
|
|
441
476
|
// before a status was reported).
|
|
442
477
|
if ((session.status ?? "working") !== "working") continue;
|
|
478
|
+
// Silent agent → stop asserting it's working; hand the row to the cron.
|
|
479
|
+
if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) continue;
|
|
443
480
|
client
|
|
444
481
|
.updateAgentProgress(session.cardId, {
|
|
445
482
|
agentIdentifier: session.agentIdentifier,
|
|
446
483
|
agentName: session.agentName,
|
|
447
484
|
status: "working",
|
|
485
|
+
noCreate: true,
|
|
486
|
+
})
|
|
487
|
+
.then((res) => {
|
|
488
|
+
// Ended out-of-band (UI "Stop", another client, the stale cron). Drop
|
|
489
|
+
// it so we never beat — nor respawn — this session again. Only an
|
|
490
|
+
// explicit null counts: an unknown/absent field must not untrack a
|
|
491
|
+
// live session.
|
|
492
|
+
//
|
|
493
|
+
// Identity-checked: this reply describes the session we beat, which
|
|
494
|
+
// may no longer be the one tracked for this card. An end + restart
|
|
495
|
+
// landing inside the request's round-trip replaces the map entry, and
|
|
496
|
+
// deleting by cardId alone would drop that NEW live session — leaving
|
|
497
|
+
// it unbeaten until the stale cron closed it early.
|
|
498
|
+
if (res?.session === null && scope.sessions.get(cardId) === session) {
|
|
499
|
+
scope.sessions.delete(cardId);
|
|
500
|
+
}
|
|
448
501
|
})
|
|
449
502
|
.catch(() => {
|
|
450
503
|
// Best-effort: a transient failure just defers the bump to the next
|
package/src/server.ts
CHANGED
|
@@ -3489,6 +3489,42 @@ async function handleToolCall(
|
|
|
3489
3489
|
|
|
3490
3490
|
case "harmony_set_workspace_context": {
|
|
3491
3491
|
const workspaceId = z.string().uuid().parse(args.workspaceId);
|
|
3492
|
+
|
|
3493
|
+
// Fail fast on a workspace this connection can't actually reach (#695).
|
|
3494
|
+
// An OAuth token is bound at consent time to the single workspace the user
|
|
3495
|
+
// picked ("It will be able to read and modify cards, plans, and memory in
|
|
3496
|
+
// the workspace you select below"), and harmony-api enforces that on every
|
|
3497
|
+
// call. Setting the context is a pure local assignment, so without this
|
|
3498
|
+
// check it always "succeeded" and the caller only discovered the binding
|
|
3499
|
+
// when some later, unrelated tool call 403'd mid-task — the exact confusion
|
|
3500
|
+
// this card reports. `listWorkspaces` is server-side scoped to the bound
|
|
3501
|
+
// workspace, so presence in it is the authoritative reachability answer for
|
|
3502
|
+
// OAuth and legacy-key connections alike.
|
|
3503
|
+
const { workspaces } = await client.listWorkspaces();
|
|
3504
|
+
const available = (workspaces ?? []) as Array<{
|
|
3505
|
+
id?: string;
|
|
3506
|
+
name?: string;
|
|
3507
|
+
}>;
|
|
3508
|
+
const match = available.find((w) => w?.id === workspaceId);
|
|
3509
|
+
|
|
3510
|
+
if (!match) {
|
|
3511
|
+
const options = available
|
|
3512
|
+
.filter((w) => w?.id)
|
|
3513
|
+
.map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`)
|
|
3514
|
+
.join("\n");
|
|
3515
|
+
throw new Error(
|
|
3516
|
+
`Workspace ${workspaceId} is not available to this connection.\n\n` +
|
|
3517
|
+
`This MCP connection is authorized for a specific workspace, chosen ` +
|
|
3518
|
+
`during OAuth consent — it cannot read or write any other workspace, ` +
|
|
3519
|
+
`even ones you're a member of.\n\n` +
|
|
3520
|
+
(options
|
|
3521
|
+
? `Available here:\n${options}\n\n`
|
|
3522
|
+
: `No workspaces are available to this connection.\n\n`) +
|
|
3523
|
+
`To work in a different workspace, run /mcp to reconnect and select ` +
|
|
3524
|
+
`it on the consent screen.`,
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3492
3528
|
deps.setActiveWorkspace(workspaceId);
|
|
3493
3529
|
return { success: true, activeWorkspaceId: workspaceId };
|
|
3494
3530
|
}
|