wolfpack-mcp 1.0.114 → 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.
- package/dist/index.js +30 -34
- package/dist/stdioToolPermissions.test.js +32 -2
- package/dist/toolCatalogue.js +57 -2
- package/dist/toolCatalogue.test.js +27 -1
- package/dist/updateWorkProgress.test.js +123 -0
- package/dist/workItemReminders.js +12 -0
- package/dist/workItemReminders.test.js +18 -1
- package/package.json +1 -1
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
|
-
|
|
986
|
+
const workItem = await this.client.updateWorkProgress(parsed.work_item_id, parsed.description, teamSlug);
|
|
974
987
|
if (workItem) {
|
|
975
|
-
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/dist/toolCatalogue.js
CHANGED
|
@@ -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. ``) 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:
|
|
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),
|
|
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
|
+
});
|