wolfpack-mcp 1.0.113 → 1.0.115

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.
@@ -160,12 +160,12 @@ export const AGENT_BUILDER_TOOLS = [
160
160
  },
161
161
  config: {
162
162
  type: 'object',
163
- description: 'The agent\'s own Ghost runtime settings, merged into every session\'s generated config. Two keys are honoured: "options" (e.g. {"max_turn_requests": 150} — the per-turn model request cap, default 200) and "agents" (per-agent overrides, e.g. {"coder": {"disabled_tools": ["agent"]}} to switch the sub-agent tool off). Other keys are ignored. Replaces the stored config as a whole; set to null to clear. Ghost runtime only — Claude Code agents ignore it.',
163
+ description: 'The agent\'s own Raiven (formerly Ghost) runtime settings, merged into every session\'s generated config. Two keys are honoured: "options" (e.g. {"max_turn_requests": 150} — the per-turn model request cap, default 200) and "agents" (per-agent overrides, e.g. {"coder": {"disabled_tools": ["agent"]}} to switch the sub-agent tool off). Other keys are ignored. Replaces the stored config as a whole; set to null to clear. Raiven runtime only — Claude Code agents ignore it.',
164
164
  },
165
165
  disabled_tools: {
166
166
  type: 'array',
167
167
  items: { type: 'string' },
168
- description: 'Tools the agent never gets, whatever starts the session (a task, a chat, a work-item pickup). Ghost names ("agent", "fetch"); Claude Code agents use Claude Code\'s own ("Task", "WebFetch") and "mcp__<server>__<tool>" for an MCP tool. Replaces the stored list; an empty array clears it. A task\'s own disabled_tools are added to these — a task narrows further, never widens.',
168
+ description: 'Tools the agent never gets, whatever starts the session (a task, a chat, a work-item pickup). Raiven (formerly Ghost) tool names ("agent", "fetch"); Claude Code agents use Claude Code\'s own ("Task", "WebFetch") and "mcp__<server>__<tool>" for an MCP tool. Replaces the stored list; an empty array clears it. A task\'s own disabled_tools are added to these — a task narrows further, never widens.',
169
169
  },
170
170
  disabled_mcp_servers: {
171
171
  type: 'array',
package/dist/index.js CHANGED
@@ -8,11 +8,11 @@ import { readFile, stat } from 'fs/promises';
8
8
  import { basename, extname, resolve } from 'path';
9
9
  import { WolfpackClient } from './client.js';
10
10
  import { productName } from './brand.js';
11
- import { allTasksChecked, getWorkItemReminders } from './workItemReminders.js';
11
+ import { allTasksChecked, getProgressReminder, getWorkItemReminders } from './workItemReminders.js';
12
12
  import { validateConfig, config } from './config.js';
13
13
  import { handleAgentBuilderTool } from './agentBuilderTools.js';
14
14
  import { handleProcedureTool } from './procedureTools.js';
15
- import { stdioTools, toolNamesFor, withProductName } from './toolCatalogue.js';
15
+ import { stdioTools, toolNamesFor, undeclaredArgumentsError, withProductName, } from './toolCatalogue.js';
16
16
  import { handleAgentSelfTool } from './agentSelfTools.js';
17
17
  import { handleAgentObserveTool } from './agentObserveTools.js';
18
18
  import { handleBrowserTool } from './browserTools.js';
@@ -118,6 +118,8 @@ const UpdateWorkProgressSchema = z.object({
118
118
  .optional()
119
119
  .describe('Project slug (required for multi-project users, use list_projects to get slugs)'),
120
120
  });
121
+ /** The risk vocabulary, as the backend's `VALID_RISKS` holds it. */
122
+ const VALID_RISKS = ['High', 'Medium', 'Low'];
121
123
  const VALID_STATUSES = [
122
124
  'pending',
123
125
  'new',
@@ -159,6 +161,11 @@ const UpdateWorkItemSchema = z.object({
159
161
  .nullable()
160
162
  .optional()
161
163
  .describe('Size estimate: "S" (small), "M" (medium), "L" (large), or null to remove'),
164
+ risk: z
165
+ .enum(VALID_RISKS)
166
+ .nullable()
167
+ .optional()
168
+ .describe('Risk assessment: "High", "Medium", "Low", or null to clear it'),
162
169
  assisting_user1_id: z
163
170
  .string()
164
171
  .nullable()
@@ -383,6 +390,7 @@ const CreateWorkItemSchema = z.object({
383
390
  .enum(['S', 'M', 'L'])
384
391
  .optional()
385
392
  .describe('Size estimate: "S" (small), "M" (medium), "L" (large)'),
393
+ risk: z.enum(VALID_RISKS).optional().describe('Risk assessment: "High", "Medium" or "Low"'),
386
394
  leading_user_id: z
387
395
  .string()
388
396
  .optional()
@@ -877,6 +885,11 @@ class WolfpackMCPServer {
877
885
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
878
886
  const { name, arguments: args } = request.params;
879
887
  try {
888
+ // A parameter no case below reads is dropped in silence, and the call answers as
889
+ // a success the caller cannot tell from one that applied it (#2633).
890
+ const undeclared = undeclaredArgumentsError(name, args);
891
+ if (undeclared)
892
+ throw new Error(undeclared);
880
893
  switch (name) {
881
894
  // Project handlers
882
895
  case 'list_projects': {
@@ -970,22 +983,18 @@ class WolfpackMCPServer {
970
983
  case 'update_work_progress': {
971
984
  const parsed = UpdateWorkProgressSchema.parse(args);
972
985
  const teamSlug = parsed.project_slug || this.client.getProjectSlug() || undefined;
973
- let workItem = await this.client.updateWorkProgress(parsed.work_item_id, parsed.description, teamSlug);
986
+ const workItem = await this.client.updateWorkProgress(parsed.work_item_id, parsed.description, teamSlug);
974
987
  if (workItem) {
975
- let summary = `Updated description on: ${workItem.title}`;
976
- // Recording progress means the work is under way: a "new" item starts, and an
977
- // item in "review" comes back to "doing" because its developer is working on it
978
- // again (#1787) — otherwise the board keeps showing handed-over work that is
979
- // still being changed.
980
- const previousStatus = workItem.status;
981
- if (previousStatus === 'new' || previousStatus === 'review') {
982
- const advanced = await this.moveToDoing(parsed.work_item_id, teamSlug);
983
- if (advanced) {
984
- workItem = advanced;
985
- summary += ` (status auto-advanced ${previousStatus} → doing)`;
986
- }
987
- }
988
+ // Writing a description is not a claim that work has started: it is also how a
989
+ // plan is added before pickup and how the record is tidied after handover, so
990
+ // this tool no longer moves the item (#2634). The status it left is named
991
+ // outright, and getProgressReminder says what to call to move it.
992
+ const summary = `Updated description on: ${workItem.title} (status unchanged: ${workItem.status})`;
988
993
  let text = `${summary}\n\n${JSON.stringify(stripUuids(workItem), null, 2)}`;
994
+ const reminder = getProgressReminder(workItem.status);
995
+ if (reminder) {
996
+ text = `${reminder}\n\n${text}`;
997
+ }
989
998
  if (workItem.status === 'doing' && allTasksChecked(workItem.description)) {
990
999
  text =
991
1000
  'REMINDER: All plan tasks are checked off. If the work is complete, set status to "review" NOW (update_work_item) and add a completion comment.\n\n' +
@@ -1023,6 +1032,8 @@ class WolfpackMCPServer {
1023
1032
  fields.priority = parsed.priority;
1024
1033
  if (parsed.size !== undefined)
1025
1034
  fields.size = parsed.size;
1035
+ if (parsed.risk !== undefined)
1036
+ fields.risk = parsed.risk;
1026
1037
  if (parsed.assisting_user1_id !== undefined)
1027
1038
  fields.assistingUser1Id = parsed.assisting_user1_id;
1028
1039
  if (parsed.assisting_user2_id !== undefined)
@@ -1067,6 +1078,8 @@ class WolfpackMCPServer {
1067
1078
  }
1068
1079
  if (parsed.size !== undefined)
1069
1080
  changes.push(parsed.size ? `size → ${parsed.size}` : 'size removed');
1081
+ if (parsed.risk !== undefined)
1082
+ changes.push(parsed.risk ? `risk → ${parsed.risk}` : 'risk cleared');
1070
1083
  if (parsed.assisting_user1_id !== undefined)
1071
1084
  changes.push(parsed.assisting_user1_id
1072
1085
  ? `assisting user 1 updated`
@@ -1347,6 +1360,7 @@ class WolfpackMCPServer {
1347
1360
  status: parsed.status,
1348
1361
  priority: parsed.priority,
1349
1362
  size: parsed.size,
1363
+ risk: parsed.risk,
1350
1364
  leadingUserId: parsed.leading_user_id,
1351
1365
  categoryId: parsed.category_id,
1352
1366
  radarItemId,
@@ -2079,24 +2093,6 @@ class WolfpackMCPServer {
2079
2093
  }
2080
2094
  });
2081
2095
  }
2082
- /**
2083
- * Put a work item in "doing" after progress was recorded on it, and report whether that
2084
- * stuck. An agent whose work pool may not set "doing" — a reviewer annotating an item in
2085
- * "review" rather than the developer resuming it — is refused by the backend status
2086
- * policy; that refusal is the right answer, so it leaves the saved note alone instead of
2087
- * failing the call the agent actually made.
2088
- */
2089
- async moveToDoing(workItemId, teamSlug) {
2090
- try {
2091
- return await this.client.updateWorkItem(workItemId, { status: 'doing' }, teamSlug);
2092
- }
2093
- catch (error) {
2094
- if (error && typeof error === 'object' && 'status' in error && error.status === 403) {
2095
- return null;
2096
- }
2097
- throw error;
2098
- }
2099
- }
2100
2096
  isImageBuffer(buffer) {
2101
2097
  if (buffer.length < 4)
2102
2098
  return false;
@@ -32,7 +32,8 @@ const stubBackend = async (body) => {
32
32
  await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
33
33
  return `http://127.0.0.1:${backend.address().port}/api/mcp`;
34
34
  };
35
- const toolNamesFrom = async (apiUrl) => {
35
+ /** Runs `use` against the real server, spawned and connected the way a client does. */
36
+ const withClient = async (apiUrl, use) => {
36
37
  const client = new Client({ name: 'test', version: '1.0.0' });
37
38
  const transport = new StdioClientTransport({
38
39
  // tsx directly, not via npx: the transport kills the process it spawned,
@@ -50,12 +51,15 @@ const toolNamesFrom = async (apiUrl) => {
50
51
  });
51
52
  await client.connect(transport);
52
53
  try {
53
- return (await client.listTools()).tools.map((t) => t.name);
54
+ return await use(client);
54
55
  }
55
56
  finally {
56
57
  await client.close();
57
58
  }
58
59
  };
60
+ const toolNamesFrom = async (apiUrl) => withClient(apiUrl, async (client) => (await client.listTools()).tools.map((t) => t.name));
61
+ /** What a tool call answers with, as text — the error text included. */
62
+ const callToolOver = async (apiUrl, name, args) => withClient(apiUrl, async (client) => JSON.stringify(await client.callTool({ name, arguments: args })));
59
63
  afterEach(async () => {
60
64
  await new Promise((resolve) => backend?.close(() => resolve()) ?? resolve());
61
65
  backend = undefined;
@@ -100,3 +104,29 @@ describe('the tool list served over stdio', () => {
100
104
  expect(names).toContain('update_work_item');
101
105
  }, 60_000);
102
106
  });
107
+ /**
108
+ * #2633 — the server builds each request by copying a fixed list of keys out of the
109
+ * arguments, so a parameter no case reads was dropped without a word and the call
110
+ * answered as a success. That is how `risk` went missing: the catalogue never declared
111
+ * it, so no case read it, so every attempt to set one quietly did nothing.
112
+ */
113
+ describe('an argument over stdio', () => {
114
+ const key = async () => stubBackend({ capabilities: ['project'], permissions: ['mcp:work_items:update'] });
115
+ it('is refused naming the field when the tool does not declare it (#2633)', async () => {
116
+ const answer = await callToolOver(await key(), 'update_work_item', {
117
+ work_item_id: '1',
118
+ rsik: 'High',
119
+ });
120
+ expect(answer).toContain('rsik');
121
+ expect(answer).toContain('has no parameter');
122
+ }, 60_000);
123
+ // The other half: the guard must not swallow the parameter the item added. The stub
124
+ // answers no route but a 503, so getting that far is the proof it was forwarded.
125
+ it('reaches the backend when the tool does declare it (#2633)', async () => {
126
+ const answer = await callToolOver(await key(), 'update_work_item', {
127
+ work_item_id: '1',
128
+ risk: 'High',
129
+ });
130
+ expect(answer).not.toContain('has no parameter');
131
+ }, 60_000);
132
+ });
@@ -50,6 +50,14 @@ const CONTENT_LINKING_HELP = 'CROSS-REFERENCES: In any markdown content, you can
50
50
  '#j123 or #journal-123 for journal entries, #c123 or #case-123 for cases, #p123 or #proc-123 for procedures. ' +
51
51
  'Use @username to mention team members (triggers notifications). ' +
52
52
  'Standard markdown links also work: [link text](/project/{slug}/work-items/123).';
53
+ /**
54
+ * The risk vocabulary, said the same way on every tool that carries it (#2633).
55
+ *
56
+ * The field existed on the model and in the UI from #2504 and on no write tool, so an
57
+ * agent that assessed risk while planning could only write it into the description.
58
+ */
59
+ const RISK_DESCRIPTION = 'Risk assessment: "High", "Medium" or "Low" — the exact words, capitalised. ' +
60
+ 'An item nobody has assessed has none, which is not the same as "Low".';
53
61
  const FORM_FIELD_ITEM_SCHEMA = {
54
62
  type: 'object',
55
63
  properties: {
@@ -195,6 +203,17 @@ const SHARED_TOOLS = [
195
203
  description: 'Filter by linked radar/initiative item. Accepts refId (e.g. "5" or "#r5"), UUID, ' +
196
204
  'or one of "none" (no link), "any" (has any link), "all" (no filter).',
197
205
  },
206
+ // Accepted by the stdio schema and by the controller since they were added, and
207
+ // declared on neither transport until #2633 — so undiscoverable to a client
208
+ // reading the tool list, and dropped outright by the remote server.
209
+ created_by_id: {
210
+ type: 'string',
211
+ description: 'Filter by creator user ID',
212
+ },
213
+ updated_by_id: {
214
+ type: 'string',
215
+ description: 'Filter by last updater user ID',
216
+ },
198
217
  sort_by: {
199
218
  type: 'string',
200
219
  description: 'Sort field: "dateUpdated" (default, newest activity first), "dateCreated", "priority", "title"',
@@ -239,6 +258,7 @@ const SHARED_TOOLS = [
239
258
  'REVIEW CHECKS: items under review may carry review checks (code-review, security-review, ...); see list_work_item_checks and claim/complete_work_item_check. ' +
240
259
  'DEPENDENCIES: dependsOn lists the work this item follows and nextItems the work that follows it, each with satisfied=true once it has finished. ' +
241
260
  'An item with an unsatisfied entry in dependsOn cannot be pulled yet — take the work it depends on first. ' +
261
+ `RISK: ${RISK_DESCRIPTION} Set it with update_work_item rather than writing the assessment into the description. ` +
242
262
  'IMAGES: Image references in the description (e.g. `![alt](/api/files/...)`) can be viewed using the download_image tool.',
243
263
  inputSchema: {
244
264
  type: 'object',
@@ -259,7 +279,7 @@ const SHARED_TOOLS = [
259
279
  name: 'update_work_progress',
260
280
  permission: 'mcp:work_items:update',
261
281
  description: 'Update work item description/notes with your progress. WORKFLOW: 1) get_work_item to read current notes 2) Modify the markdown description with new findings/progress 3) Call this with the complete updated description. The full description replaces the existing one. ' +
262
- 'STATUS: Items in "new" — and items in "review" you are picking back up — are auto-advanced to "doing" (recording progress means work is under way). Completing the work is NOT auto-detected: when done, set status to "review" via update_work_item and add a completion comment. ' +
282
+ 'STATUS: This tool never changes status — editing the description is also how a plan is added before pickup and how the record is tidied after handover (#2634). The response names the status it left the item in. Moving an item is always your own call: pull_work_item to start it, update_work_item to set "doing" when you pick it back up, and "review" with a completion comment when it is done. ' +
263
283
  'CRITICAL: NEVER overwrite or remove the original description text. Preserve all existing content exactly as-is. Append your plan below a "---" separator. You may only modify content you previously added (your plan section). ' +
264
284
  'BEST PRACTICE: Append a plan with markdown checkboxes (- [ ] task). Check off completed tasks (- [x] task) as you progress. Include sections for: Plan (checklist), Approach (strategy), and Notes (discoveries/decisions). ' +
265
285
  CONTENT_LINKING_HELP,
@@ -284,7 +304,8 @@ const SHARED_TOOLS = [
284
304
  permission: 'mcp:work_items:update',
285
305
  description: 'Update one or more fields on a work item. Only provide the fields you want to change. ' +
286
306
  'Supports: title, description, status, leading_user_id (assignee), assisting_user1/2/3_id, ' +
287
- 'radar_item_id (initiative), category_id, priority (0-4), size (S/M/L), work_pool_id, tags, and closing_comment. ' +
307
+ 'radar_item_id (initiative), category_id, priority (0-4), size (S/M/L), risk (High/Medium/Low), ' +
308
+ 'work_pool_id, tags, and closing_comment. ' +
288
309
  'STATUS WORKFLOW: "pending" (backlog) → "new" (to do) → "doing" (in progress) → "review" (work done) → "ready" (awaiting deployment) → "completed" (deployed). ' +
289
310
  'Use "blocked" when work cannot proceed. AGENTS: you cannot start un-started work here — ' +
290
311
  'a status change on a "pending" or "new" item that is not assigned to you is refused. ' +
@@ -348,6 +369,11 @@ const SHARED_TOOLS = [
348
369
  enum: ['S', 'M', 'L', null],
349
370
  description: 'Size estimate: "S" (small), "M" (medium), "L" (large), or null to remove',
350
371
  },
372
+ risk: {
373
+ type: ['string', 'null'],
374
+ enum: ['High', 'Medium', 'Low', null],
375
+ description: `${RISK_DESCRIPTION} Use null to clear it.`,
376
+ },
351
377
  assisting_user1_id: {
352
378
  type: ['string', 'null'],
353
379
  description: 'User ID for first assisting user, or null to remove',
@@ -851,6 +877,11 @@ const SHARED_TOOLS = [
851
877
  enum: ['S', 'M', 'L'],
852
878
  description: 'Size estimate: "S" (small), "M" (medium), "L" (large)',
853
879
  },
880
+ risk: {
881
+ type: 'string',
882
+ enum: ['High', 'Medium', 'Low'],
883
+ description: RISK_DESCRIPTION,
884
+ },
854
885
  leading_user_id: {
855
886
  type: 'string',
856
887
  description: 'User ID to assign as leading user. AGENTS: you may put your own id here — filing work you already know you will do is the one assignment that is yours to make. Leaving it out is just as normal: the Delivery Lead assigns it instead. Either way the item waits in the backlog for a human to approve before you can pull it.',
@@ -2313,6 +2344,30 @@ export function stdioTools(capabilities, permissions) {
2313
2344
  return TOOL_CATALOGUE.filter((t) => (!t.capability || capabilities.includes(t.capability)) &&
2314
2345
  (!t.permission || !permissions || holdsScope(permissions, t.permission))).map(toTool);
2315
2346
  }
2347
+ /**
2348
+ * The complaint about arguments the tool does not declare, or null when there are none.
2349
+ *
2350
+ * Both servers build a tool's request by copying a fixed list of keys out of the
2351
+ * arguments, so anything else was dropped without a word and the call answered as a
2352
+ * success — `risk` on `update_work_item` was set by hand in the UI for weeks because of
2353
+ * it (#2633). A silently ignored parameter is the worst failure mode available: the
2354
+ * caller cannot tell it from one that was applied.
2355
+ *
2356
+ * The backend's remote server holds its own copy of this, against the generated
2357
+ * catalogue; keep the two saying the same thing.
2358
+ */
2359
+ export function undeclaredArgumentsError(name, args) {
2360
+ const tool = TOOL_CATALOGUE.find((t) => t.name === name);
2361
+ // A name no tool answers to is the dispatch's own complaint to make, not ours.
2362
+ if (!tool)
2363
+ return null;
2364
+ const declared = Object.keys(tool.inputSchema?.properties ?? {});
2365
+ const undeclared = Object.keys(args ?? {}).filter((key) => !declared.includes(key));
2366
+ if (undeclared.length === 0)
2367
+ return null;
2368
+ return (`${name} has no parameter ${undeclared.map((k) => `"${k}"`).join(', ')}. ` +
2369
+ `It accepts: ${declared.join(', ')}.`);
2370
+ }
2316
2371
  /** The names a capability gates, for routing a call to that family's handler. */
2317
2372
  export function toolNamesFor(capability) {
2318
2373
  return TOOL_CATALOGUE.filter((t) => t.capability === capability).map((t) => t.name);
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import * as catalogue from './toolCatalogue.js';
5
- import { TOOL_CATALOGUE, remoteTools, toolPermissions, stdioTools, toolNamesFor, } from './toolCatalogue.js';
5
+ import { TOOL_CATALOGUE, remoteTools, toolPermissions, stdioTools, toolNamesFor, undeclaredArgumentsError, } from './toolCatalogue.js';
6
6
  import { BROWSER_TOOLS } from './browserTools.js';
7
7
  import { AGENT_SELF_TOOLS, AGENT_MEMORY_TOOLS } from './agentSelfTools.js';
8
8
  import { AGENT_OBSERVE_TOOLS } from './agentObserveTools.js';
@@ -132,6 +132,32 @@ describe('the MCP tool catalogue', () => {
132
132
  expect(stdioTools(['project']).map((t) => t.name)).toEqual(stdioTools(['project'], null).map((t) => t.name));
133
133
  expect(stdioTools(['project'], null).map((t) => t.name)).toContain('update_work_item');
134
134
  });
135
+ it('declares the risk on both tools that write a work item (#2633)', () => {
136
+ // The column, the pill and the vocabulary all shipped with #2504 and no write tool
137
+ // carried the field, so an agent could read an assessment and never record one.
138
+ const risk = (name) => TOOL_CATALOGUE.find((t) => t.name === name).inputSchema.properties.risk;
139
+ expect(risk('create_work_item').enum).toEqual(['High', 'Medium', 'Low']);
140
+ // Nullable on the update alone: clearing an assessment is a thing to do to an item
141
+ // that has one, and "not assessed" is not a value you create an item with.
142
+ expect(risk('update_work_item').enum).toEqual(['High', 'Medium', 'Low', null]);
143
+ // And the read says what the words mean, so a caller need not guess the casing.
144
+ const read = TOOL_CATALOGUE.find((t) => t.name === 'get_work_item');
145
+ expect(read.description).toContain('"High", "Medium" or "Low"');
146
+ });
147
+ it('complains about an argument the tool does not declare (#2633)', () => {
148
+ expect(undeclaredArgumentsError('update_work_item', { work_item_id: '1', risk: 'High' })).toBeNull();
149
+ expect(undeclaredArgumentsError('update_work_item', undefined)).toBeNull();
150
+ expect(undeclaredArgumentsError('update_work_item', { work_item_id: '1', rsik: 'High' })).toContain('update_work_item has no parameter "rsik". It accepts: work_item_id, title,');
151
+ // A name no tool answers to is the dispatch's own complaint, not this one's.
152
+ expect(undeclaredArgumentsError('no_such_tool', { anything: 1 })).toBeNull();
153
+ });
154
+ it('guards the remote dispatch in the same words (#2633)', () => {
155
+ // The catalogue crosses the package boundary as data, so the remote server holds
156
+ // its own copy of the check. Two copies may not say two different things.
157
+ const server = repoFile('packages/backend/src/mcp/server.ts');
158
+ expect(server).toContain('has no parameter');
159
+ expect(server).toContain('It accepts: ');
160
+ });
135
161
  it('takes the remote override only where one is set', () => {
136
162
  // upload_image is the single tool whose two clients cannot share a contract.
137
163
  const overridden = TOOL_CATALOGUE.filter((t) => t.remote).map((t) => t.name);
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+ import { createServer } from 'http';
5
+ import { fileURLToPath } from 'url';
6
+ /**
7
+ * #2634 — what `update_work_progress` actually sends.
8
+ *
9
+ * The defect was a second request nobody asked for: after writing the description the
10
+ * server fired `PATCH /work-items/{id} {status:'doing'}` whenever the item sat in "new"
11
+ * or "review", so a planning edit started unstarted work and a bookkeeping tick pulled
12
+ * handed-over work back off the review queue. Asserting on the response text alone would
13
+ * not have caught it — the write is the defect — so this drives the real server over a
14
+ * stdio transport and records every request the stub backend receives.
15
+ */
16
+ const entryPoint = fileURLToPath(new URL('./index.ts', import.meta.url));
17
+ let backend;
18
+ /** Every request the server made, as "METHOD /path" with the query string dropped. */
19
+ let requests = [];
20
+ /**
21
+ * A backend that answers the progress write with an item in `status`, and would answer a
22
+ * status PATCH too — the test has to fail on the recorded request, not on an error the
23
+ * call happened to raise.
24
+ */
25
+ const stubBackend = async (status) => {
26
+ const workItem = (itemStatus) => ({
27
+ id: 'aa3d9bd0-7f3a-4d2f-8a26-61b1b0b0f001',
28
+ refId: 42,
29
+ title: 'Risk assessment wanted',
30
+ description: 'Original text\n\n---\n\n- [ ] first task',
31
+ status: itemStatus,
32
+ });
33
+ backend = createServer((req, res) => {
34
+ const path = (req.url ?? '').split('?')[0];
35
+ requests.push(`${req.method} ${path}`);
36
+ const json = (body) => {
37
+ res.writeHead(200, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify(body));
39
+ };
40
+ if (path.endsWith('/capabilities')) {
41
+ return json({ capabilities: ['project'], permissions: ['mcp:work_items:update'] });
42
+ }
43
+ if (path.endsWith('/teams')) {
44
+ return json({ teams: [] });
45
+ }
46
+ if (req.method === 'PUT' && path.endsWith('/progress')) {
47
+ return json(workItem(status));
48
+ }
49
+ if (req.method === 'PATCH' && path.includes('/work-items/')) {
50
+ return json(workItem('doing'));
51
+ }
52
+ res.writeHead(503).end();
53
+ });
54
+ await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
55
+ return `http://127.0.0.1:${backend.address().port}/api/mcp`;
56
+ };
57
+ /** The text `update_work_progress` answers with, driven the way a client drives it. */
58
+ const recordProgress = async (apiUrl) => {
59
+ const client = new Client({ name: 'test', version: '1.0.0' });
60
+ const transport = new StdioClientTransport({
61
+ // tsx directly, not via npx: the transport kills the process it spawned, and an npx
62
+ // wrapper would leave the server orphaned behind it.
63
+ command: process.execPath,
64
+ args: ['--import', 'tsx', entryPoint],
65
+ env: {
66
+ ...process.env,
67
+ WOLFPACK_API_KEY: 'wfp_sk_test',
68
+ WOLFPACK_API_URL: apiUrl,
69
+ WOLFPACK_PROJECT_SLUG: '',
70
+ WOLFPACK_TEAM_SLUG: '',
71
+ WOLFPACK_ORG_SLUG: '',
72
+ },
73
+ });
74
+ await client.connect(transport);
75
+ try {
76
+ const result = (await client.callTool({
77
+ name: 'update_work_progress',
78
+ arguments: {
79
+ work_item_id: '42',
80
+ description: 'Original text\n\n---\n\n- [ ] first task',
81
+ },
82
+ }));
83
+ return result.content.map((c) => c.text).join('\n');
84
+ }
85
+ finally {
86
+ await client.close();
87
+ }
88
+ };
89
+ afterEach(async () => {
90
+ await new Promise((resolve) => backend?.close(() => resolve()) ?? resolve());
91
+ backend = undefined;
92
+ requests = [];
93
+ });
94
+ describe('update_work_progress over stdio', () => {
95
+ it('writes the description of an item in "new" and nothing else (#2629)', async () => {
96
+ const text = await recordProgress(await stubBackend('new'));
97
+ expect(requests).toContain('PUT /api/mcp/work-items/42/progress');
98
+ expect(requests.filter((r) => r.startsWith('PATCH'))).toEqual([]);
99
+ expect(text).not.toContain('auto-advanced');
100
+ expect(text).toContain('(status unchanged: new)');
101
+ expect(text).toContain('"status": "new"');
102
+ }, 60_000);
103
+ it('leaves an item in "review" on the review queue (#2628)', async () => {
104
+ const text = await recordProgress(await stubBackend('review'));
105
+ expect(requests.filter((r) => r.startsWith('PATCH'))).toEqual([]);
106
+ expect(text).toContain('(status unchanged: review)');
107
+ expect(text).toContain('"status": "review"');
108
+ }, 60_000);
109
+ it('tells a caller who IS picking the item up what to call', async () => {
110
+ // The convenience is not simply removed: the response says what the tool no longer
111
+ // does for you, so resuming work still puts the item in "doing" — by the caller's own
112
+ // update_work_item, which is the only thing that should move it (#1787 kept honest).
113
+ const text = await recordProgress(await stubBackend('review'));
114
+ expect(text).toContain('set status to "doing" (update_work_item)');
115
+ }, 60_000);
116
+ it('still reports the status of an item already under way', async () => {
117
+ const text = await recordProgress(await stubBackend('doing'));
118
+ expect(requests.filter((r) => r.startsWith('PATCH'))).toEqual([]);
119
+ expect(text).toContain('(status unchanged: doing)');
120
+ // Nothing to pick up, so no pick-it-up reminder
121
+ expect(text).not.toContain('BEFORE starting');
122
+ }, 60_000);
123
+ });
@@ -29,6 +29,18 @@ const STATUS_REMINDERS = {
29
29
  'up (rework, review feedback, a follow-up change), set status to "doing" (update_work_item) BEFORE ' +
30
30
  'starting, so the board shows the work is under way again. If you are reviewing it, leave it where it is.',
31
31
  };
32
+ /**
33
+ * The reminder an `update_work_progress` response carries, or null when there is nothing to
34
+ * act on. Recording progress used to move the item itself (#2634): a planning edit started
35
+ * work nobody had started, and a bookkeeping tick pulled handed-over work back out of
36
+ * "review". Only the caller knows which it is, so the two statuses that used to be moved for
37
+ * them are the two that now get told what to call — the same words get_work_item uses.
38
+ */
39
+ export function getProgressReminder(status) {
40
+ if (status !== 'new' && status !== 'review')
41
+ return null;
42
+ return STATUS_REMINDERS[status];
43
+ }
32
44
  // Reminders to prepend to a get_work_item response, most urgent first.
33
45
  export function getWorkItemReminders(status, description, approved, question) {
34
46
  const reminders = [];
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { hasPlan, allTasksChecked, getWorkItemReminders } from './workItemReminders.js';
2
+ import { hasPlan, allTasksChecked, getProgressReminder, getWorkItemReminders, } from './workItemReminders.js';
3
3
  const PLAN = 'Original text\n\n---\n\n## Plan\n\n- [x] first task\n- [ ] second task';
4
4
  const DONE_PLAN = 'Original text\n\n---\n\n## Plan\n\n- [x] first task\n- [x] second task';
5
5
  describe('hasPlan', () => {
@@ -76,3 +76,20 @@ describe('getWorkItemReminders', () => {
76
76
  expect(getWorkItemReminders('ready', PLAN)).toEqual([]);
77
77
  });
78
78
  });
79
+ describe('getProgressReminder', () => {
80
+ // #2634 — these two statuses are exactly the ones a description edit used to move for the
81
+ // caller. The reminder replaces the write, so it has to fire on both and say what to call.
82
+ it('tells a caller picking up a "new" item to set "doing" first', () => {
83
+ expect(getProgressReminder('new')).toContain('set status to "doing" (update_work_item)');
84
+ });
85
+ it('tells a caller reworking a handed-over item to set "doing", and a reviewer to leave it', () => {
86
+ const reminder = getProgressReminder('review');
87
+ expect(reminder).toContain('set status to "doing" (update_work_item)');
88
+ expect(reminder).toContain('If you are reviewing it, leave it where it is');
89
+ });
90
+ // Work already under way needs no nudge, and a finished item must not be invited back:
91
+ // suggesting "doing" on "ready" or "completed" would re-open the #2628 occurrence in words.
92
+ it.each(['pending', 'doing', 'paused', 'blocked', 'ready', 'completed', 'closed'])('stays quiet on %s', (status) => {
93
+ expect(getProgressReminder(status)).toBeNull();
94
+ });
95
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolfpack-mcp",
3
- "version": "1.0.113",
3
+ "version": "1.0.115",
4
4
  "description": "MCP server for Clachan (formerly Wolfpack), AI-enhanced software delivery tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",