wolfpack-mcp 1.0.114 → 1.0.116
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/README.md +12 -0
- package/dist/index.js +37 -34
- package/dist/stdioToolPermissions.test.js +32 -2
- package/dist/toolCatalogue.js +72 -4
- 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/README.md
CHANGED
|
@@ -285,6 +285,8 @@ Get a wiki page by ID or slug.
|
|
|
285
285
|
|
|
286
286
|
- `page_id` (required): UUID or slug (e.g., "getting-started")
|
|
287
287
|
|
|
288
|
+
Returns a `version` alongside the content. Quote it back as `expected_version` when you save.
|
|
289
|
+
|
|
288
290
|
#### `create_wiki_page`
|
|
289
291
|
|
|
290
292
|
Create a new wiki page.
|
|
@@ -298,8 +300,18 @@ Create a new wiki page.
|
|
|
298
300
|
Update an existing wiki page.
|
|
299
301
|
|
|
300
302
|
- `page_id` (required): The page UUID
|
|
303
|
+
- `expected_version` (required): The `version` `get_wiki_page` returned
|
|
301
304
|
- `title`, `content` (optional)
|
|
302
305
|
|
|
306
|
+
**Read, then write.** `content` replaces the whole document, and wiki pages are edited by
|
|
307
|
+
people at the same time as an agent, so a save made from a copy read earlier would throw away
|
|
308
|
+
everything written since — which is what happened before #2681. `expected_version` is therefore
|
|
309
|
+
required, and a save quoting a version somebody has moved past is refused with 409: read the
|
|
310
|
+
page again, re-apply the change to what it says now, and save with the new version. Never
|
|
311
|
+
re-send the copy you were holding.
|
|
312
|
+
|
|
313
|
+
`create_wiki_page` is the exception — a page being created has no prior version.
|
|
314
|
+
|
|
303
315
|
### Journal Entries
|
|
304
316
|
|
|
305
317
|
#### `list_journal_entries`
|
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()
|
|
@@ -500,6 +508,12 @@ const UpdateWikiPageSchema = z.object({
|
|
|
500
508
|
page_id: z.string().describe('The page slug'),
|
|
501
509
|
title: z.string().optional().describe('Updated title'),
|
|
502
510
|
content: z.string().optional().describe('Updated content (markdown)'),
|
|
511
|
+
// Optional here, required by the server: a missing version is refused by the backend with
|
|
512
|
+
// a message saying how to get one, which is more use than a parser error (#2681).
|
|
513
|
+
expected_version: z
|
|
514
|
+
.string()
|
|
515
|
+
.optional()
|
|
516
|
+
.describe('The `version` get_wiki_page returned for this page'),
|
|
503
517
|
});
|
|
504
518
|
// Journal Entry schemas
|
|
505
519
|
const ListJournalEntriesSchema = z.object({
|
|
@@ -877,6 +891,11 @@ class WolfpackMCPServer {
|
|
|
877
891
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
878
892
|
const { name, arguments: args } = request.params;
|
|
879
893
|
try {
|
|
894
|
+
// A parameter no case below reads is dropped in silence, and the call answers as
|
|
895
|
+
// a success the caller cannot tell from one that applied it (#2633).
|
|
896
|
+
const undeclared = undeclaredArgumentsError(name, args);
|
|
897
|
+
if (undeclared)
|
|
898
|
+
throw new Error(undeclared);
|
|
880
899
|
switch (name) {
|
|
881
900
|
// Project handlers
|
|
882
901
|
case 'list_projects': {
|
|
@@ -970,22 +989,18 @@ class WolfpackMCPServer {
|
|
|
970
989
|
case 'update_work_progress': {
|
|
971
990
|
const parsed = UpdateWorkProgressSchema.parse(args);
|
|
972
991
|
const teamSlug = parsed.project_slug || this.client.getProjectSlug() || undefined;
|
|
973
|
-
|
|
992
|
+
const workItem = await this.client.updateWorkProgress(parsed.work_item_id, parsed.description, teamSlug);
|
|
974
993
|
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
|
-
}
|
|
994
|
+
// Writing a description is not a claim that work has started: it is also how a
|
|
995
|
+
// plan is added before pickup and how the record is tidied after handover, so
|
|
996
|
+
// this tool no longer moves the item (#2634). The status it left is named
|
|
997
|
+
// outright, and getProgressReminder says what to call to move it.
|
|
998
|
+
const summary = `Updated description on: ${workItem.title} (status unchanged: ${workItem.status})`;
|
|
988
999
|
let text = `${summary}\n\n${JSON.stringify(stripUuids(workItem), null, 2)}`;
|
|
1000
|
+
const reminder = getProgressReminder(workItem.status);
|
|
1001
|
+
if (reminder) {
|
|
1002
|
+
text = `${reminder}\n\n${text}`;
|
|
1003
|
+
}
|
|
989
1004
|
if (workItem.status === 'doing' && allTasksChecked(workItem.description)) {
|
|
990
1005
|
text =
|
|
991
1006
|
'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 +1038,8 @@ class WolfpackMCPServer {
|
|
|
1023
1038
|
fields.priority = parsed.priority;
|
|
1024
1039
|
if (parsed.size !== undefined)
|
|
1025
1040
|
fields.size = parsed.size;
|
|
1041
|
+
if (parsed.risk !== undefined)
|
|
1042
|
+
fields.risk = parsed.risk;
|
|
1026
1043
|
if (parsed.assisting_user1_id !== undefined)
|
|
1027
1044
|
fields.assistingUser1Id = parsed.assisting_user1_id;
|
|
1028
1045
|
if (parsed.assisting_user2_id !== undefined)
|
|
@@ -1067,6 +1084,8 @@ class WolfpackMCPServer {
|
|
|
1067
1084
|
}
|
|
1068
1085
|
if (parsed.size !== undefined)
|
|
1069
1086
|
changes.push(parsed.size ? `size → ${parsed.size}` : 'size removed');
|
|
1087
|
+
if (parsed.risk !== undefined)
|
|
1088
|
+
changes.push(parsed.risk ? `risk → ${parsed.risk}` : 'risk cleared');
|
|
1070
1089
|
if (parsed.assisting_user1_id !== undefined)
|
|
1071
1090
|
changes.push(parsed.assisting_user1_id
|
|
1072
1091
|
? `assisting user 1 updated`
|
|
@@ -1347,6 +1366,7 @@ class WolfpackMCPServer {
|
|
|
1347
1366
|
status: parsed.status,
|
|
1348
1367
|
priority: parsed.priority,
|
|
1349
1368
|
size: parsed.size,
|
|
1369
|
+
risk: parsed.risk,
|
|
1350
1370
|
leadingUserId: parsed.leading_user_id,
|
|
1351
1371
|
categoryId: parsed.category_id,
|
|
1352
1372
|
radarItemId,
|
|
@@ -1468,6 +1488,7 @@ class WolfpackMCPServer {
|
|
|
1468
1488
|
const page = await this.client.updateWikiPage(parsed.page_id, {
|
|
1469
1489
|
title: parsed.title,
|
|
1470
1490
|
content: parsed.content,
|
|
1491
|
+
expectedVersion: parsed.expected_version,
|
|
1471
1492
|
});
|
|
1472
1493
|
return {
|
|
1473
1494
|
content: [
|
|
@@ -2079,24 +2100,6 @@ class WolfpackMCPServer {
|
|
|
2079
2100
|
}
|
|
2080
2101
|
});
|
|
2081
2102
|
}
|
|
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
2103
|
isImageBuffer(buffer) {
|
|
2101
2104
|
if (buffer.length < 4)
|
|
2102
2105
|
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.',
|
|
@@ -1005,6 +1036,8 @@ const SHARED_TOOLS = [
|
|
|
1005
1036
|
name: 'get_wiki_page',
|
|
1006
1037
|
permission: 'mcp:wiki:read',
|
|
1007
1038
|
description: 'Get a specific wiki/documentation page by slug (URL path). Returns full content in markdown. ' +
|
|
1039
|
+
'VERSION: the response carries a `version`. Pass it to update_wiki_page as expected_version when you save, ' +
|
|
1040
|
+
'and read the page again — taking a fresh version — if the save is refused because somebody changed it. ' +
|
|
1008
1041
|
'IMAGES: Image references in the content (e.g. ``) can be viewed using the download_image tool.',
|
|
1009
1042
|
inputSchema: {
|
|
1010
1043
|
type: 'object',
|
|
@@ -1045,7 +1078,13 @@ const SHARED_TOOLS = [
|
|
|
1045
1078
|
{
|
|
1046
1079
|
name: 'update_wiki_page',
|
|
1047
1080
|
permission: 'mcp:wiki:update',
|
|
1048
|
-
description: 'Update an existing wiki page. Requires mcp:wiki:update permission. ' +
|
|
1081
|
+
description: 'Update an existing wiki page. Requires mcp:wiki:update permission. ' +
|
|
1082
|
+
'READ BEFORE YOU WRITE: call get_wiki_page first and pass the `version` it returned as expected_version. ' +
|
|
1083
|
+
'Wiki pages are edited by people at the same time as you, and `content` replaces the whole document, ' +
|
|
1084
|
+
'so a save made from a copy you read earlier would throw away everything written since. ' +
|
|
1085
|
+
'If the save is refused because the page changed, do NOT send the same content again: ' +
|
|
1086
|
+
'call get_wiki_page, re-apply your change to what the page says now, and save with the new version. ' +
|
|
1087
|
+
CONTENT_LINKING_HELP,
|
|
1049
1088
|
// No tag_ids and no project_slug: `PATCH /api/mcp/wiki-pages/{pageId}` has
|
|
1050
1089
|
// neither. The body model is `additionalProperties: false`, so a call
|
|
1051
1090
|
// carrying tags was refused outright rather than ignored, and the page
|
|
@@ -1061,8 +1100,13 @@ const SHARED_TOOLS = [
|
|
|
1061
1100
|
type: 'string',
|
|
1062
1101
|
description: 'Updated content (markdown)',
|
|
1063
1102
|
},
|
|
1103
|
+
expected_version: {
|
|
1104
|
+
type: 'string',
|
|
1105
|
+
description: 'The `version` get_wiki_page returned for this page. Required: the save is refused if ' +
|
|
1106
|
+
'somebody has changed the page since, so that their edit is not thrown away.',
|
|
1107
|
+
},
|
|
1064
1108
|
},
|
|
1065
|
-
required: ['page_id'],
|
|
1109
|
+
required: ['page_id', 'expected_version'],
|
|
1066
1110
|
},
|
|
1067
1111
|
},
|
|
1068
1112
|
// Journal Entry tools
|
|
@@ -2313,6 +2357,30 @@ export function stdioTools(capabilities, permissions) {
|
|
|
2313
2357
|
return TOOL_CATALOGUE.filter((t) => (!t.capability || capabilities.includes(t.capability)) &&
|
|
2314
2358
|
(!t.permission || !permissions || holdsScope(permissions, t.permission))).map(toTool);
|
|
2315
2359
|
}
|
|
2360
|
+
/**
|
|
2361
|
+
* The complaint about arguments the tool does not declare, or null when there are none.
|
|
2362
|
+
*
|
|
2363
|
+
* Both servers build a tool's request by copying a fixed list of keys out of the
|
|
2364
|
+
* arguments, so anything else was dropped without a word and the call answered as a
|
|
2365
|
+
* success — `risk` on `update_work_item` was set by hand in the UI for weeks because of
|
|
2366
|
+
* it (#2633). A silently ignored parameter is the worst failure mode available: the
|
|
2367
|
+
* caller cannot tell it from one that was applied.
|
|
2368
|
+
*
|
|
2369
|
+
* The backend's remote server holds its own copy of this, against the generated
|
|
2370
|
+
* catalogue; keep the two saying the same thing.
|
|
2371
|
+
*/
|
|
2372
|
+
export function undeclaredArgumentsError(name, args) {
|
|
2373
|
+
const tool = TOOL_CATALOGUE.find((t) => t.name === name);
|
|
2374
|
+
// A name no tool answers to is the dispatch's own complaint to make, not ours.
|
|
2375
|
+
if (!tool)
|
|
2376
|
+
return null;
|
|
2377
|
+
const declared = Object.keys(tool.inputSchema?.properties ?? {});
|
|
2378
|
+
const undeclared = Object.keys(args ?? {}).filter((key) => !declared.includes(key));
|
|
2379
|
+
if (undeclared.length === 0)
|
|
2380
|
+
return null;
|
|
2381
|
+
return (`${name} has no parameter ${undeclared.map((k) => `"${k}"`).join(', ')}. ` +
|
|
2382
|
+
`It accepts: ${declared.join(', ')}.`);
|
|
2383
|
+
}
|
|
2316
2384
|
/** The names a capability gates, for routing a call to that family's handler. */
|
|
2317
2385
|
export function toolNamesFor(capability) {
|
|
2318
2386
|
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
|
+
});
|