@ezmodo/mcp-server 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/endpoint-map.js +20 -0
- package/handlers/decisions.js +43 -1
- package/handlers/epics.js +120 -0
- package/handlers/index.js +9 -0
- package/handlers/unmapped-paths.js +60 -0
- package/http.js +52 -22
- package/index.js +7 -5
- package/lib/create-server.js +5 -11
- package/lib/http-client.js +6 -0
- package/lib/http-diagnostics.js +53 -0
- package/lib/logger.js +23 -6
- package/lib/remote-tools.js +8 -0
- package/lib/version.js +1 -1
- package/package.json +5 -3
- package/tools/decisions.js +86 -8
- package/tools/epics.js +231 -0
- package/tools/index.js +2 -0
- package/tools/unmapped-paths.js +72 -0
package/config/endpoint-map.js
CHANGED
|
@@ -29,6 +29,17 @@ export const ENDPOINT_MAP = {
|
|
|
29
29
|
'mcpSearchEpics': { route: 'mcp/v1/epics/search', method: 'POST' },
|
|
30
30
|
'mcpListEpics': { route: 'mcp/v1/epics', method: 'GET' },
|
|
31
31
|
'mcpGetEpic': { route: 'mcp/v1/epics/by-id', method: 'GET' },
|
|
32
|
+
// E-259: epic plan with revisions.
|
|
33
|
+
'mcpGetEpicPlan': { route: 'mcp/v1/epics/plan', method: 'GET' },
|
|
34
|
+
'mcpUpdateEpicPlan': { route: 'mcp/v1/epics/plan', method: 'PUT' },
|
|
35
|
+
'mcpListEpicComments': { route: 'mcp/v1/epics/comments', method: 'GET' },
|
|
36
|
+
// Catch me up (E-259 #2746).
|
|
37
|
+
'mcpGetEpicActivity': { route: 'mcp/v1/epics/activity', method: 'GET' },
|
|
38
|
+
// Plan proposals (E-259 #2745): suggest a change to a plan you cannot save.
|
|
39
|
+
'mcpListPlanProposals': { route: 'mcp/v1/epics/proposals', method: 'GET' },
|
|
40
|
+
'mcpProposePlanChange': { route: 'mcp/v1/epics/proposals', method: 'POST' },
|
|
41
|
+
'mcpReviewPlanProposal': { route: 'mcp/v1/epics/proposals/review', method: 'POST' },
|
|
42
|
+
'mcpAddEpicComment': { route: 'mcp/v1/epics/comments', method: 'POST' },
|
|
32
43
|
// E-237 #2382: the epic is the fifth consumer of the grounding engine.
|
|
33
44
|
'mcpGenerateEpicHowItWorks': { route: 'mcp/v1/epics/generate-how-it-works', method: 'POST' },
|
|
34
45
|
'mcpApplyEpicHowItWorks': { route: 'mcp/v1/epics/apply-how-it-works', method: 'POST' },
|
|
@@ -209,6 +220,11 @@ export const ENDPOINT_MAP = {
|
|
|
209
220
|
'mcpPromoteDecisionFromKnowledge': { route: 'mcp/v1/decisions/promote-from-knowledge', method: 'POST' },
|
|
210
221
|
'mcpLinkDecisionArtifact': { route: 'mcp/v1/decisions/link', method: 'POST' },
|
|
211
222
|
'mcpUnlinkDecisionArtifact': { route: 'mcp/v1/decisions/link', method: 'DELETE' },
|
|
223
|
+
// Decisions to make on an epic (E-259).
|
|
224
|
+
'mcpAddDecisionInput': { route: 'mcp/v1/decisions/inputs', method: 'POST' },
|
|
225
|
+
'mcpDecideDecision': { route: 'mcp/v1/decisions/decide', method: 'POST' },
|
|
226
|
+
'mcpHoldTaskForDecision': { route: 'mcp/v1/decisions/holds', method: 'POST' },
|
|
227
|
+
'mcpReleaseTaskFromDecision': { route: 'mcp/v1/decisions/holds', method: 'DELETE' },
|
|
212
228
|
|
|
213
229
|
// Recurring task schedules (E-211) — "what task to create, on what cadence".
|
|
214
230
|
'mcpCreateRecurringTask': { route: 'mcp/v1/recurring-tasks', method: 'POST' },
|
|
@@ -254,6 +270,10 @@ export const ENDPOINT_MAP = {
|
|
|
254
270
|
'mcpDiscoverScreens': { route: 'mcp/v1/screens/discover', method: 'GET' },
|
|
255
271
|
'mcpImportScreens': { route: 'mcp/v1/screens/import', method: 'POST' },
|
|
256
272
|
'mcpSyncScreens': { route: 'mcp/v1/screens/sync', method: 'POST' },
|
|
273
|
+
'mcpListUnmappedPaths': { route: 'mcp/v1/unmapped-paths', method: 'GET' },
|
|
274
|
+
'mcpAssignUnmappedPath': { route: 'mcp/v1/unmapped-paths/assign', method: 'POST' },
|
|
275
|
+
'mcpDismissUnmappedPath': { route: 'mcp/v1/unmapped-paths/dismiss', method: 'POST' },
|
|
276
|
+
'mcpReconcileUnmappedPaths': { route: 'mcp/v1/unmapped-paths/reconcile', method: 'POST' },
|
|
257
277
|
'mcpLinkCatalog': { route: 'mcp/v1/catalogs/link', method: 'POST' },
|
|
258
278
|
'mcpUnlinkCatalog': { route: 'mcp/v1/catalogs/link', method: 'DELETE' },
|
|
259
279
|
// Item-level links (E-218) — attach work or an external URL to one catalog entry
|
package/handlers/decisions.js
CHANGED
|
@@ -25,8 +25,13 @@ export async function manageDecision(args) {
|
|
|
25
25
|
case 'unlink': return unlinkDecisionArtifact(params);
|
|
26
26
|
case 'supersede': return supersedeDecision(params);
|
|
27
27
|
case 'promote_from_knowledge': return promoteFromKnowledge(params);
|
|
28
|
+
case 'add_input': return addDecisionInput(params);
|
|
29
|
+
case 'decide': return decideDecision(params);
|
|
30
|
+
case 'hold_task': return holdTask(params);
|
|
31
|
+
case 'release_task': return releaseTask(params);
|
|
28
32
|
default:
|
|
29
|
-
throw new Error(`Unknown action: ${action}. Expected create, update, delete, link, unlink,
|
|
33
|
+
throw new Error(`Unknown action: ${action}. Expected create, update, delete, link, unlink, ` +
|
|
34
|
+
'supersede, promote_from_knowledge, add_input, decide, hold_task, or release_task.');
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -48,6 +53,13 @@ export async function getDecision(args) {
|
|
|
48
53
|
return result;
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
// Decisions to make on an epic (E-259).
|
|
57
|
+
if (filters.epicId) {
|
|
58
|
+
const params = { epicId: filters.epicId };
|
|
59
|
+
if (filters.status) params.status = filters.status;
|
|
60
|
+
return callZephlyAPI('mcpListDecisions', params);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
// List mode
|
|
52
64
|
const params = {};
|
|
53
65
|
if (organizationId) params.organizationId = organizationId;
|
|
@@ -66,6 +78,10 @@ export async function getDecision(args) {
|
|
|
66
78
|
async function createDecision(args) {
|
|
67
79
|
// `links` is applied by the MCP layer after the decision exists (E-225).
|
|
68
80
|
const { links, ...createArgs } = args;
|
|
81
|
+
// On create the server takes option labels; accept {label} objects too.
|
|
82
|
+
if (Array.isArray(createArgs.choices)) {
|
|
83
|
+
createArgs.choices = createArgs.choices.map((c) => (typeof c === 'string' ? c : c?.label));
|
|
84
|
+
}
|
|
69
85
|
const result = await callZephlyAPI('mcpCreateDecision', createArgs);
|
|
70
86
|
|
|
71
87
|
// Attach create-time links (E-225) — best effort, never fails the create.
|
|
@@ -79,6 +95,11 @@ async function createDecision(args) {
|
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
async function updateDecision(args) {
|
|
98
|
+
// On update the server takes the full list as {id, label}; a bare string is
|
|
99
|
+
// a new option.
|
|
100
|
+
if (Array.isArray(args.choices)) {
|
|
101
|
+
args = { ...args, choices: args.choices.map((c) => (typeof c === 'string' ? { label: c } : c)) };
|
|
102
|
+
}
|
|
82
103
|
return callZephlyAPI('mcpUpdateDecision', args);
|
|
83
104
|
}
|
|
84
105
|
|
|
@@ -112,3 +133,24 @@ async function promoteFromKnowledge({ organizationId, taskId, knowledgeId, title
|
|
|
112
133
|
linkToId,
|
|
113
134
|
});
|
|
114
135
|
}
|
|
136
|
+
|
|
137
|
+
// --- Decisions to make on an epic (E-259) ---
|
|
138
|
+
|
|
139
|
+
// Record a pick. It counts for the person whose key is used, and replaces
|
|
140
|
+
// their earlier pick; the server records which AI made it.
|
|
141
|
+
async function addDecisionInput({ decisionId, choiceId, reason }) {
|
|
142
|
+
return callZephlyAPI('mcpAddDecisionInput', { decisionId, choiceId, reason });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Decide. The server refuses anyone but the epic's owner or an editor.
|
|
146
|
+
async function decideDecision({ decisionId, status, choiceId, decision, rejectedReasons }) {
|
|
147
|
+
return callZephlyAPI('mcpDecideDecision', { decisionId, status, choiceId, decision, rejectedReasons });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function holdTask({ decisionId, taskId }) {
|
|
151
|
+
return callZephlyAPI('mcpHoldTaskForDecision', { decisionId, taskId });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function releaseTask({ decisionId, taskId }) {
|
|
155
|
+
return callZephlyAPI('mcpReleaseTaskFromDecision', { decisionId, taskId });
|
|
156
|
+
}
|
package/handlers/epics.js
CHANGED
|
@@ -188,3 +188,123 @@ export async function getEpic(args) {
|
|
|
188
188
|
if (webUrl && result?.epic) result.epic.webUrl = webUrl;
|
|
189
189
|
return result;
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read an epic's plan with its current revision (E-259).
|
|
194
|
+
*/
|
|
195
|
+
export async function getEpicPlan(args) {
|
|
196
|
+
return callZephlyAPI('mcpGetEpicPlan', args);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Save an epic's plan against the revision it was read at (E-259). A conflict
|
|
201
|
+
* is returned as a result, not thrown: it is an expected outcome when several
|
|
202
|
+
* people's AIs share a plan, and the agent needs the current plan and what
|
|
203
|
+
* changed to redo its edit.
|
|
204
|
+
*/
|
|
205
|
+
export async function updateEpicPlan(args) {
|
|
206
|
+
try {
|
|
207
|
+
return await callZephlyAPI('mcpUpdateEpicPlan', args);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (err?.code === 'PLAN_CONFLICT') {
|
|
210
|
+
const details = err.details || {};
|
|
211
|
+
return {
|
|
212
|
+
saved: false,
|
|
213
|
+
conflict: true,
|
|
214
|
+
message: `Someone else changed this plan since revision ${details.baseRevision}. ` +
|
|
215
|
+
'Nothing was saved. Apply your change to the current plan below and save again with ' +
|
|
216
|
+
`baseRevision ${details.currentRevision}. Do not resend your old copy.`,
|
|
217
|
+
currentRevision: details.currentRevision,
|
|
218
|
+
changesSince: details.changesSince || [],
|
|
219
|
+
currentPlan: details.current || null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Catch me up (E-259 #2746): what changed on an epic since the caller last
|
|
228
|
+
* looked. Marks it caught up unless markSeen is false.
|
|
229
|
+
*/
|
|
230
|
+
export async function getEpicActivity(args) {
|
|
231
|
+
const params = { epicId: args.epicId };
|
|
232
|
+
if (args.since) params.since = args.since;
|
|
233
|
+
if (args.markSeen === false) params.markSeen = 'false';
|
|
234
|
+
return callZephlyAPI('mcpGetEpicActivity', params);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* An epic's discussion, oldest first (E-259).
|
|
239
|
+
*/
|
|
240
|
+
export async function listEpicComments(args) {
|
|
241
|
+
return callZephlyAPI('mcpListEpicComments', args);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Post to an epic's discussion, or reply in a thread (E-259).
|
|
246
|
+
*/
|
|
247
|
+
export async function addEpicComment(args) {
|
|
248
|
+
return callZephlyAPI('mcpAddEpicComment', args);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Suggest a change to an epic's plan, and answer suggestions (E-259 #2745).
|
|
253
|
+
*
|
|
254
|
+
* One tool with actions rather than five tool names: the tool list is read by
|
|
255
|
+
* every agent on every call, so each new name costs everyone.
|
|
256
|
+
*/
|
|
257
|
+
export async function managePlanProposal(args = {}) {
|
|
258
|
+
const { action } = args;
|
|
259
|
+
|
|
260
|
+
switch (action) {
|
|
261
|
+
case 'propose': {
|
|
262
|
+
if (!args.epicId) throw new Error('epicId is required to suggest a change');
|
|
263
|
+
if (!args.plan && !args.ops) {
|
|
264
|
+
throw new Error('Send the plan you want (or the individual changes) to suggest a change');
|
|
265
|
+
}
|
|
266
|
+
return callZephlyAPI('mcpProposePlanChange', {
|
|
267
|
+
epicId: args.epicId,
|
|
268
|
+
plan: args.plan,
|
|
269
|
+
ops: args.ops,
|
|
270
|
+
title: args.title,
|
|
271
|
+
rationale: args.rationale,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'list': {
|
|
276
|
+
if (!args.epicId) throw new Error('epicId is required to list proposals');
|
|
277
|
+
const params = { epicId: args.epicId };
|
|
278
|
+
if (args.status) params.status = args.status;
|
|
279
|
+
return callZephlyAPI('mcpListPlanProposals', params);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'get': {
|
|
283
|
+
if (!args.proposalId) throw new Error('proposalId is required');
|
|
284
|
+
return callZephlyAPI('mcpListPlanProposals', { proposalId: args.proposalId });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case 'review': {
|
|
288
|
+
if (!args.proposalId) throw new Error('proposalId is required to answer a proposal');
|
|
289
|
+
if (!args.accept?.length && !args.reject?.length) {
|
|
290
|
+
throw new Error('Say which changes you are taking (accept) and which you are not (reject)');
|
|
291
|
+
}
|
|
292
|
+
return callZephlyAPI('mcpReviewPlanProposal', {
|
|
293
|
+
proposalId: args.proposalId,
|
|
294
|
+
accept: args.accept || [],
|
|
295
|
+
reject: args.reject || [],
|
|
296
|
+
note: args.note,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'withdraw': {
|
|
301
|
+
if (!args.proposalId) throw new Error('proposalId is required to take back a proposal');
|
|
302
|
+
return callZephlyAPI('mcpReviewPlanProposal', { proposalId: args.proposalId, withdraw: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Unknown action "${action}". Use propose, list, get, review or withdraw.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
package/handlers/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as featureHandlers from './features.js';
|
|
|
14
14
|
import * as decisionHandlers from './decisions.js';
|
|
15
15
|
import * as designHandlers from './designs.js';
|
|
16
16
|
import * as catalogHandlers from './catalogs.js';
|
|
17
|
+
import * as unmappedPathHandlers from './unmapped-paths.js';
|
|
17
18
|
import * as featureFlagHandlers from './feature-flags.js';
|
|
18
19
|
import * as taskHandlers from './tasks.js';
|
|
19
20
|
import * as documentHandlers from './documents.js';
|
|
@@ -58,6 +59,12 @@ export const HANDLERS = {
|
|
|
58
59
|
search_epics: epicHandlers.searchEpics,
|
|
59
60
|
list_epics: epicHandlers.listEpics,
|
|
60
61
|
get_epic: epicHandlers.getEpic,
|
|
62
|
+
get_epic_plan: epicHandlers.getEpicPlan,
|
|
63
|
+
update_epic_plan: epicHandlers.updateEpicPlan,
|
|
64
|
+
list_epic_comments: epicHandlers.listEpicComments,
|
|
65
|
+
get_epic_activity: epicHandlers.getEpicActivity,
|
|
66
|
+
manage_plan_proposal: epicHandlers.managePlanProposal,
|
|
67
|
+
add_epic_comment: epicHandlers.addEpicComment,
|
|
61
68
|
|
|
62
69
|
// Milestones
|
|
63
70
|
manage_milestone: milestoneHandlers.manageMilestone,
|
|
@@ -86,6 +93,8 @@ export const HANDLERS = {
|
|
|
86
93
|
manage_catalog: catalogHandlers.manageCatalog,
|
|
87
94
|
get_catalog: catalogHandlers.getCatalog,
|
|
88
95
|
list_catalogs: catalogHandlers.listCatalogs,
|
|
96
|
+
list_unmapped_paths: unmappedPathHandlers.listUnmappedPaths,
|
|
97
|
+
resolve_unmapped: unmappedPathHandlers.resolveUnmapped,
|
|
89
98
|
list_catalog_items: catalogHandlers.listCatalogItems,
|
|
90
99
|
get_catalog_diff: catalogHandlers.getCatalogDiff,
|
|
91
100
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unmapped code path handlers (E-258 #2756)
|
|
3
|
+
*
|
|
4
|
+
* Thin wrappers over /api/mcp/v1/unmapped-paths. Dispatch and validation live
|
|
5
|
+
* server-side in core/unmappedpaths.Service; these only shape the arguments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { callZephlyAPI } from '../lib/http-client.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* List a project's unmapped paths, pending by default.
|
|
12
|
+
*/
|
|
13
|
+
export async function listUnmappedPaths({ projectId, status }) {
|
|
14
|
+
if (!projectId) {
|
|
15
|
+
throw new Error('projectId is required');
|
|
16
|
+
}
|
|
17
|
+
const params = { projectId };
|
|
18
|
+
if (status) params.status = status;
|
|
19
|
+
return callZephlyAPI('mcpListUnmappedPaths', params);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dispatch resolve_unmapped actions.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveUnmapped(args) {
|
|
26
|
+
const { action, ...params } = args;
|
|
27
|
+
switch (action) {
|
|
28
|
+
case 'reconcile': return reconcileUnmappedPaths(params);
|
|
29
|
+
case 'assign': return assignUnmappedPath(params);
|
|
30
|
+
case 'dismiss': return dismissUnmappedPath(params);
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unknown action: ${action}. Expected reconcile, assign, or dismiss.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Close every pending row an existing feature path already covers. Rows several
|
|
37
|
+
// features could claim are left pending on purpose.
|
|
38
|
+
async function reconcileUnmappedPaths({ projectId }) {
|
|
39
|
+
requireProject(projectId, 'reconcile');
|
|
40
|
+
return callZephlyAPI('mcpReconcileUnmappedPaths', { projectId });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function assignUnmappedPath({ projectId, pathId, featureId }) {
|
|
44
|
+
requireProject(projectId, 'assign');
|
|
45
|
+
if (!pathId) throw new Error('pathId is required for assign');
|
|
46
|
+
if (!featureId) throw new Error('featureId is required for assign');
|
|
47
|
+
return callZephlyAPI('mcpAssignUnmappedPath', { projectId, pathId, featureId });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function dismissUnmappedPath({ projectId, pathId }) {
|
|
51
|
+
requireProject(projectId, 'dismiss');
|
|
52
|
+
if (!pathId) throw new Error('pathId is required for dismiss');
|
|
53
|
+
return callZephlyAPI('mcpDismissUnmappedPath', { projectId, pathId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireProject(projectId, action) {
|
|
57
|
+
if (!projectId) {
|
|
58
|
+
throw new Error(`projectId is required for ${action}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/http.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* lib/create-server.js, so the tool surface cannot differ between them.
|
|
8
8
|
*
|
|
9
9
|
* ── Stateless, and why ────────────────────────────────────────────────────────
|
|
10
|
-
* A fresh Server
|
|
11
|
-
*
|
|
10
|
+
* A fresh Server is built per request: SDK v2's createMcpHandler calls the
|
|
11
|
+
* factory each time, and serves 2025-era clients through the same stateless
|
|
12
|
+
* idiom v1 used (`sessionIdGenerator: undefined`, a transport per request).
|
|
13
|
+
* 2026-07-28 is stateless by design. The alternative — stateful sessions held in
|
|
12
14
|
* memory — cannot survive the deployment target: Cloud Run runs several
|
|
13
15
|
* instances with no session affinity, so a client's second request routinely
|
|
14
16
|
* lands on an instance that has never heard of its session and is rejected with
|
|
@@ -35,7 +37,9 @@
|
|
|
35
37
|
|
|
36
38
|
import { createServer as createHttpServer } from 'node:http';
|
|
37
39
|
import { randomUUID } from 'node:crypto';
|
|
38
|
-
import {
|
|
40
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
41
|
+
import { createMcpHandler } from '@modelcontextprotocol/server';
|
|
42
|
+
import { toNodeHandler } from '@modelcontextprotocol/node';
|
|
39
43
|
|
|
40
44
|
import { createServer } from './lib/create-server.js';
|
|
41
45
|
import { withRequestContext } from './lib/request-context.js';
|
|
@@ -43,12 +47,15 @@ import { initLogger, getLogger } from './lib/logger.js';
|
|
|
43
47
|
import { MCP_VERSION } from './lib/version.js';
|
|
44
48
|
import { CONFIG } from './config/index.js';
|
|
45
49
|
import { getApiUrl } from './lib/env.js';
|
|
50
|
+
import { healthPaths, describeRejectedRequest } from './lib/http-diagnostics.js';
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
// Structured: this process's stderr is read by Cloud Logging, not a person.
|
|
53
|
+
initLogger(false, undefined, { structured: true });
|
|
48
54
|
const log = getLogger();
|
|
49
55
|
|
|
50
56
|
const PORT = Number(process.env.PORT || 8080);
|
|
51
57
|
const MCP_PATH = process.env.MCP_HTTP_PATH || '/mcp';
|
|
58
|
+
const HEALTH_PATHS = healthPaths(MCP_PATH);
|
|
52
59
|
|
|
53
60
|
// OAuth discovery (#2601). MCP_PUBLIC_URL is this server's public identity —
|
|
54
61
|
// the "resource" in RFC 9728 terms — and must be the URL a client actually
|
|
@@ -120,6 +127,43 @@ export function bearerToken(headerValue) {
|
|
|
120
127
|
return token ? token : null;
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
// The request being served, for the handler-level onerror below. The SDK
|
|
131
|
+
// reports why it refused a request only through that callback, which is set
|
|
132
|
+
// once for the whole process; this is how a report finds its way back to the
|
|
133
|
+
// request it is about. Same mechanism as the credential in request-context.js.
|
|
134
|
+
const inFlight = new AsyncLocalStorage();
|
|
135
|
+
|
|
136
|
+
// One handler for the process; the factory builds a fresh server for each
|
|
137
|
+
// request. It serves protocol revision 2026-07-28 (the `server/discover`
|
|
138
|
+
// Claude opens every connection with, which SDK v1 answered with a 400) and,
|
|
139
|
+
// by default (legacy: 'stateless'), 2025-era clients the same stateless way
|
|
140
|
+
// the v1 transport did. See the stateless note at the top.
|
|
141
|
+
//
|
|
142
|
+
// 'remote': excludes tools that operate on a local checkout, which do not
|
|
143
|
+
// exist here and whose git helpers shell out with caller-supplied arguments
|
|
144
|
+
// (#2614).
|
|
145
|
+
const mcpHandler = createMcpHandler(() => createServer({ surface: 'remote' }), {
|
|
146
|
+
onerror: (error) => {
|
|
147
|
+
const current = inFlight.getStore();
|
|
148
|
+
log.warn('MCP transport rejected request', {
|
|
149
|
+
requestId: current?.requestId,
|
|
150
|
+
error: error?.message || String(error),
|
|
151
|
+
...(current ? describeRejectedRequest(current.req, current.body) : {}),
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const serveMcp = toNodeHandler(mcpHandler, {
|
|
157
|
+
// The adapter itself failed (converting the request, or the handler threw)
|
|
158
|
+
// and is about to answer 500.
|
|
159
|
+
onerror: (error) => {
|
|
160
|
+
log.error('MCP handler failed', {
|
|
161
|
+
requestId: inFlight.getStore()?.requestId,
|
|
162
|
+
error: error?.message || String(error),
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
123
167
|
function sendJson(res, status, body, extraHeaders = {}) {
|
|
124
168
|
const payload = JSON.stringify(body);
|
|
125
169
|
res.writeHead(status, {
|
|
@@ -173,22 +217,8 @@ async function handleMcpPost(req, res, requestId) {
|
|
|
173
217
|
return rpcError(res, status, -32700, `Could not parse request body: ${error.message}`);
|
|
174
218
|
}
|
|
175
219
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// exist here and whose git helpers shell out with caller-supplied arguments
|
|
179
|
-
// (#2614).
|
|
180
|
-
const server = createServer({ surface: 'remote' });
|
|
181
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
182
|
-
|
|
183
|
-
// Closing on response end matters: without it every request leaks a transport
|
|
184
|
-
// and its server, and the leak only shows up under sustained load.
|
|
185
|
-
res.on('close', () => {
|
|
186
|
-
transport.close().catch(() => {});
|
|
187
|
-
server.close().catch(() => {});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
await server.connect(transport);
|
|
191
|
-
await withRequestContext({ apiKey: token }, () => transport.handleRequest(req, res, body));
|
|
220
|
+
await withRequestContext({ apiKey: token }, () =>
|
|
221
|
+
inFlight.run({ requestId, req, body }, () => serveMcp(req, res, body)));
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
const httpServer = createHttpServer(async (req, res) => {
|
|
@@ -220,7 +250,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
220
250
|
});
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
if (
|
|
253
|
+
if (HEALTH_PATHS.has(url.pathname)) {
|
|
224
254
|
return sendJson(res, 200, { status: 'ok', version: MCP_VERSION, environment: CONFIG.environment });
|
|
225
255
|
}
|
|
226
256
|
|
|
@@ -278,7 +308,7 @@ httpServer.listen(PORT, () => {
|
|
|
278
308
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
279
309
|
process.on(signal, () => {
|
|
280
310
|
log.info('Shutting down', { signal });
|
|
281
|
-
httpServer.close(() => process.exit(0));
|
|
311
|
+
httpServer.close(() => mcpHandler.close().finally(() => process.exit(0)));
|
|
282
312
|
setTimeout(() => process.exit(0), 10_000).unref();
|
|
283
313
|
});
|
|
284
314
|
}
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* projects, tasks, and documentation via HTTP API.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
11
11
|
|
|
12
12
|
// Import configuration
|
|
13
13
|
import { CONFIG } from './config/index.js';
|
|
@@ -65,11 +65,13 @@ console.error('');
|
|
|
65
65
|
// build the same server through lib/create-server.js, so the tool surface
|
|
66
66
|
// cannot differ between them.
|
|
67
67
|
|
|
68
|
-
const server = createServer();
|
|
69
|
-
|
|
70
68
|
async function main() {
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
// serveStdio, not server.connect(new StdioServerTransport()): a Server wired
|
|
70
|
+
// straight to the transport speaks only the 2025-era protocol (SDK v2
|
|
71
|
+
// migration guide). serveStdio lets the client's opening message pick the
|
|
72
|
+
// era, 2026-07-28 or 2025, and pins one instance from the factory for the
|
|
73
|
+
// life of the connection.
|
|
74
|
+
serveStdio(() => createServer());
|
|
73
75
|
log.info('MCP server running on stdio');
|
|
74
76
|
console.error('✅ ezmodo MCP Server running on stdio');
|
|
75
77
|
}
|
package/lib/create-server.js
CHANGED
|
@@ -12,13 +12,7 @@
|
|
|
12
12
|
* credential in the request context.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { Server } from '@modelcontextprotocol/
|
|
16
|
-
import {
|
|
17
|
-
CallToolRequestSchema,
|
|
18
|
-
ListToolsRequestSchema,
|
|
19
|
-
ListPromptsRequestSchema,
|
|
20
|
-
GetPromptRequestSchema,
|
|
21
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
22
16
|
|
|
23
17
|
import { TOOLS } from '../tools/index.js';
|
|
24
18
|
import { HANDLERS } from '../handlers/index.js';
|
|
@@ -105,9 +99,9 @@ export function createServer({ surface = 'local', startSignIn = defaultStartSign
|
|
|
105
99
|
}
|
|
106
100
|
);
|
|
107
101
|
|
|
108
|
-
server.setRequestHandler(
|
|
102
|
+
server.setRequestHandler('tools/list', async () => ({ tools }));
|
|
109
103
|
|
|
110
|
-
server.setRequestHandler(
|
|
104
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
111
105
|
const { name, arguments: args } = request.params;
|
|
112
106
|
|
|
113
107
|
// Checked before the handler lookup: a tool excluded from this surface is
|
|
@@ -214,11 +208,11 @@ export function createServer({ surface = 'local', startSignIn = defaultStartSign
|
|
|
214
208
|
|
|
215
209
|
// Filtered by surface exactly as tools are, and for the same reason: `submit`
|
|
216
210
|
// reads git SHAs and links commits, which a hosted server cannot do.
|
|
217
|
-
server.setRequestHandler(
|
|
211
|
+
server.setRequestHandler('prompts/list', async () => ({
|
|
218
212
|
prompts: listPrompts(surface),
|
|
219
213
|
}));
|
|
220
214
|
|
|
221
|
-
server.setRequestHandler(
|
|
215
|
+
server.setRequestHandler('prompts/get', async (request) => {
|
|
222
216
|
const content = getPromptContent(request.params.name, request.params.arguments, surface);
|
|
223
217
|
if (!content) {
|
|
224
218
|
throw new Error(`Unknown prompt: ${request.params.name}`);
|
package/lib/http-client.js
CHANGED
|
@@ -172,6 +172,12 @@ export async function callZephlyAPI(endpoint, data) {
|
|
|
172
172
|
if (typeof error.retryable === 'boolean') {
|
|
173
173
|
thrown.retryable = error.retryable;
|
|
174
174
|
}
|
|
175
|
+
// Structured details, when the API sent an object rather than a sentence —
|
|
176
|
+
// a plan conflict (E-259) carries the current plan and what changed, which
|
|
177
|
+
// is exactly what the agent needs to redo its edit.
|
|
178
|
+
if (error.details && typeof error.details === 'object') {
|
|
179
|
+
thrown.details = error.details;
|
|
180
|
+
}
|
|
175
181
|
throw thrown;
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small pure helpers for the HTTP transport (http.js), kept here because
|
|
3
|
+
* http.js starts listening the moment it is imported and so cannot be tested.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The paths that answer a health check.
|
|
8
|
+
*
|
|
9
|
+
* `/health` and `/healthz` are what Cloud Run's probes dial, straight at the
|
|
10
|
+
* container. `${mcpPath}/health` is the only one reachable from OUTSIDE: the
|
|
11
|
+
* load balancer forwards just `/mcp` and `/mcp/*` here, with the path
|
|
12
|
+
* unchanged, and `/health` at the domain root belongs to the web app. The
|
|
13
|
+
* uptime check has always probed `/mcp/health`, and until this was added it
|
|
14
|
+
* got a 404 every minute.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} mcpPath
|
|
17
|
+
* @returns {Set<string>}
|
|
18
|
+
*/
|
|
19
|
+
export function healthPaths(mcpPath) {
|
|
20
|
+
return new Set(['/health', '/healthz', `${mcpPath}/health`]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What to log about a request the MCP transport refused.
|
|
25
|
+
*
|
|
26
|
+
* The SDK answers a malformed or unsupported request with a 400 and reports
|
|
27
|
+
* why only through `transport.onerror`. Without this, production logged a 400
|
|
28
|
+
* at the start of every Claude Desktop session and nothing else, so nobody
|
|
29
|
+
* could tell which request it was or what the SDK disliked about it.
|
|
30
|
+
*
|
|
31
|
+
* Only the shape is kept: JSON-RPC methods and the protocol headers. Never the
|
|
32
|
+
* Authorization header, and never params, which can carry user content.
|
|
33
|
+
*
|
|
34
|
+
* @param {import('node:http').IncomingMessage} req
|
|
35
|
+
* @param {unknown} body parsed JSON body, possibly a batch array
|
|
36
|
+
*/
|
|
37
|
+
export function describeRejectedRequest(req, body) {
|
|
38
|
+
const messages = Array.isArray(body) ? body : body ? [body] : [];
|
|
39
|
+
const methods = messages.map((m) => (m && typeof m === 'object' && typeof m.method === 'string'
|
|
40
|
+
? m.method
|
|
41
|
+
: '(no method)'));
|
|
42
|
+
const initialize = messages.find((m) => m?.method === 'initialize');
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
methods,
|
|
46
|
+
batch: Array.isArray(body),
|
|
47
|
+
protocolVersionHeader: req.headers['mcp-protocol-version'] ?? null,
|
|
48
|
+
sessionIdHeader: req.headers['mcp-session-id'] ? 'present' : null,
|
|
49
|
+
initializeProtocolVersion: initialize?.params?.protocolVersion ?? null,
|
|
50
|
+
accept: req.headers.accept ?? null,
|
|
51
|
+
contentType: req.headers['content-type'] ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/lib/logger.js
CHANGED
|
@@ -19,6 +19,11 @@ import { homedir } from 'os';
|
|
|
19
19
|
const RETENTION_DAYS = 7;
|
|
20
20
|
const DATE_PATTERN = /^ezmodo-(\d{4}-\d{2}-\d{2})\.log$/;
|
|
21
21
|
|
|
22
|
+
// Cloud Logging's LogSeverity names. `warn` must become WARNING: an
|
|
23
|
+
// unrecognised severity is stored as DEFAULT, which a severity>=WARNING filter
|
|
24
|
+
// never matches.
|
|
25
|
+
const CLOUD_SEVERITY = { debug: 'DEBUG', info: 'INFO', warn: 'WARNING', error: 'ERROR' };
|
|
26
|
+
|
|
22
27
|
function getDateString() {
|
|
23
28
|
return new Date().toISOString().slice(0, 10);
|
|
24
29
|
}
|
|
@@ -32,14 +37,28 @@ function getLogFilePath(logsDir) {
|
|
|
32
37
|
* @param {string} [options.source='mcp']
|
|
33
38
|
* @param {boolean} [options.verbose=false]
|
|
34
39
|
* @param {string} [options.logsDir]
|
|
40
|
+
* @param {boolean} [options.structured=false] write stderr lines as one JSON
|
|
41
|
+
* object each — see stderrLine
|
|
35
42
|
*/
|
|
36
43
|
export function createLogger(options = {}) {
|
|
37
44
|
const {
|
|
38
45
|
source = 'mcp',
|
|
39
46
|
verbose = false,
|
|
40
47
|
logsDir = join(homedir(), '.ezmodo', 'logs'),
|
|
48
|
+
structured = false,
|
|
41
49
|
} = options;
|
|
42
50
|
|
|
51
|
+
// Over stdio a person reads stderr in a terminal, so it stays a short line.
|
|
52
|
+
// Over HTTP it is read by Cloud Logging, which parses a JSON line into
|
|
53
|
+
// jsonPayload and maps `severity`; and the log FILE is on an ephemeral
|
|
54
|
+
// container disk nobody ever sees. Plain lines there meant the connector's
|
|
55
|
+
// warnings reached production as a bare message, with every field
|
|
56
|
+
// (requestId, error, what was rejected) lost.
|
|
57
|
+
function stderrLine(level, msg, data) {
|
|
58
|
+
if (!structured) return `[${source}] ${msg}`;
|
|
59
|
+
return JSON.stringify({ severity: CLOUD_SEVERITY[level], message: msg, source, ...data });
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// Ensure logs directory exists (fire-and-forget)
|
|
44
63
|
let dirReady = mkdir(logsDir, { recursive: true }).catch(() => {});
|
|
45
64
|
|
|
@@ -59,10 +78,8 @@ export function createLogger(options = {}) {
|
|
|
59
78
|
}).catch(() => {});
|
|
60
79
|
|
|
61
80
|
// stderr routing: warn/error always go to stderr; debug/info only if verbose
|
|
62
|
-
if (level === 'warn' || level === 'error') {
|
|
63
|
-
console.error(
|
|
64
|
-
} else if (verbose) {
|
|
65
|
-
console.error(`[${source}] ${msg}`);
|
|
81
|
+
if (level === 'warn' || level === 'error' || verbose) {
|
|
82
|
+
console.error(stderrLine(level, msg, data));
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
85
|
|
|
@@ -101,8 +118,8 @@ export function createLogger(options = {}) {
|
|
|
101
118
|
// Singleton
|
|
102
119
|
let _logger = null;
|
|
103
120
|
|
|
104
|
-
export function initLogger(verbose = false, logsDir) {
|
|
105
|
-
_logger = createLogger({ source: 'mcp', verbose, logsDir });
|
|
121
|
+
export function initLogger(verbose = false, logsDir, { structured = false } = {}) {
|
|
122
|
+
_logger = createLogger({ source: 'mcp', verbose, logsDir, structured });
|
|
106
123
|
_logger.cleanup();
|
|
107
124
|
return _logger;
|
|
108
125
|
}
|
package/lib/remote-tools.js
CHANGED
|
@@ -58,6 +58,7 @@ export const LOCAL_ONLY_TOOLS = Object.freeze([
|
|
|
58
58
|
/** Tools safe to serve over the remote transport. */
|
|
59
59
|
export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
60
60
|
'accept_agent_suggestion',
|
|
61
|
+
'add_epic_comment',
|
|
61
62
|
'configure_agent',
|
|
62
63
|
'create_tasks',
|
|
63
64
|
'delete_attachment',
|
|
@@ -75,6 +76,8 @@ export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
|
75
76
|
'get_document',
|
|
76
77
|
'get_document_template',
|
|
77
78
|
'get_epic',
|
|
79
|
+
'get_epic_activity',
|
|
80
|
+
'get_epic_plan',
|
|
78
81
|
'get_feature',
|
|
79
82
|
'get_feature_flag',
|
|
80
83
|
'get_goal',
|
|
@@ -93,7 +96,9 @@ export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
|
93
96
|
'list_attachments',
|
|
94
97
|
'list_catalog_items',
|
|
95
98
|
'list_catalogs',
|
|
99
|
+
'list_unmapped_paths',
|
|
96
100
|
'list_designs',
|
|
101
|
+
'list_epic_comments',
|
|
97
102
|
'list_epics',
|
|
98
103
|
'list_facts',
|
|
99
104
|
'list_feature_flags',
|
|
@@ -139,10 +144,13 @@ export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
|
139
144
|
'resolve_concepts',
|
|
140
145
|
'resolve_link_suggestions',
|
|
141
146
|
'resolve_links',
|
|
147
|
+
'resolve_unmapped',
|
|
148
|
+
'manage_plan_proposal',
|
|
142
149
|
'run_agent_now',
|
|
143
150
|
'search_epics',
|
|
144
151
|
'search_features',
|
|
145
152
|
'search_tasks',
|
|
153
|
+
'update_epic_plan',
|
|
146
154
|
'update_manifest_entries',
|
|
147
155
|
'validate_manifest',
|
|
148
156
|
]);
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ezmodo/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "MCP server for ezmodo - AI-first project management",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
"email": "help@ezmodo.com"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@modelcontextprotocol/
|
|
63
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
64
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
65
|
+
"hono": "^4.13.8",
|
|
64
66
|
"node-fetch": "^3.3.2"
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
|
@@ -70,6 +72,6 @@
|
|
|
70
72
|
"jest": "^30.5.1"
|
|
71
73
|
},
|
|
72
74
|
"engines": {
|
|
73
|
-
"node": ">=
|
|
75
|
+
"node": ">=20.0.0"
|
|
74
76
|
}
|
|
75
77
|
}
|
package/tools/decisions.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* Decisions capture "why we built it this way" so the rationale survives the work
|
|
12
12
|
* that produced it. A task's decision-type knowledge item can be elevated into a
|
|
13
13
|
* durable Decision via promote_from_knowledge.
|
|
14
|
+
*
|
|
15
|
+
* Decisions to make (E-259): a proposed Decision asked on an epic, with a plain
|
|
16
|
+
* question, options, a recommendation and the people who should weigh in. Each
|
|
17
|
+
* of them, or their AI, adds a pick (add_input); the epic's owner or an editor
|
|
18
|
+
* decides (decide). A decision can hold tasks back until it is decided.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { LINKABLE_TYPES } from './linkable-types.js';
|
|
@@ -23,17 +28,81 @@ export const DECISION_TOOLS = [
|
|
|
23
28
|
'options, decision, consequences, status), link/unlink it to existing artifacts, mark it ' +
|
|
24
29
|
'superseded by another decision, or promote a task\'s decision-type knowledge item into a ' +
|
|
25
30
|
'durable Decision. Decisions are org-level and LINK to features/epics/tasks/milestones/etc., ' +
|
|
26
|
-
'they do not contain them
|
|
31
|
+
'they do not contain them.\n\n' +
|
|
32
|
+
'DECISIONS TO MAKE (open choices on an epic that several people weigh in on): create with ' +
|
|
33
|
+
'epicId + question + choices + recommendation + requestedFrom (the people whose view is ' +
|
|
34
|
+
'wanted; they are notified). Write the question, options and recommendation in plain ' +
|
|
35
|
+
'language a non-developer can answer. Then "add_input" records a pick (choiceId + a ' +
|
|
36
|
+
'one-line reason; your pick counts for the person whose key you use, and replaces their ' +
|
|
37
|
+
'earlier one). "decide" is for the epic\'s owner or an editor only: pass choiceId, ' +
|
|
38
|
+
'optionally decision (the answer in plain words) and rejectedReasons {choiceId: why}. ' +
|
|
39
|
+
'"hold_task" makes a task wait until the decision is made (it is blocked, and released on ' +
|
|
40
|
+
'decide); "release_task" undoes that. Do not decide on someone\'s behalf unless they asked ' +
|
|
41
|
+
'you to — add a pick instead.',
|
|
27
42
|
inputSchema: {
|
|
28
43
|
type: 'object',
|
|
29
44
|
properties: {
|
|
30
45
|
action: {
|
|
31
46
|
type: 'string',
|
|
32
|
-
enum: ['create', 'update', 'delete', 'link', 'unlink', 'supersede', 'promote_from_knowledge'
|
|
47
|
+
enum: ['create', 'update', 'delete', 'link', 'unlink', 'supersede', 'promote_from_knowledge',
|
|
48
|
+
'add_input', 'decide', 'hold_task', 'release_task'],
|
|
33
49
|
description: 'Action to perform. "supersede" marks a decision as superseded by another ' +
|
|
34
50
|
'(pass decisionId + supersededById). "promote_from_knowledge" elevates a task\'s ' +
|
|
35
51
|
'decision-type knowledge item into a durable Decision (pass organizationId + taskId + ' +
|
|
36
|
-
'knowledgeId, optionally title + linkToType/linkToId to link it on creation).'
|
|
52
|
+
'knowledgeId, optionally title + linkToType/linkToId to link it on creation). ' +
|
|
53
|
+
'"add_input" (decisionId + choiceId + reason), "decide" (decisionId + choiceId), ' +
|
|
54
|
+
'"hold_task" / "release_task" (decisionId + taskId) are for decisions to make on an epic.',
|
|
55
|
+
},
|
|
56
|
+
// --- Decisions to make (E-259) ---
|
|
57
|
+
epicId: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Ask this decision on an epic (create). Its owner or an editor decides it; the ' +
|
|
60
|
+
'decision is scoped to the epic\'s project and linked to it.',
|
|
61
|
+
},
|
|
62
|
+
question: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'The plain question to answer, e.g. "Who should review changes to the plan?" ' +
|
|
65
|
+
'(create, update)',
|
|
66
|
+
},
|
|
67
|
+
recommendation: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'The recommended answer and why, in a sentence or two (create, update)',
|
|
70
|
+
},
|
|
71
|
+
choices: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
items: {
|
|
74
|
+
oneOf: [
|
|
75
|
+
{ type: 'string' },
|
|
76
|
+
{
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: { id: { type: 'string' }, label: { type: 'string' } },
|
|
79
|
+
required: ['label'],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
description: 'The options to pick from. On create, a list of plain labels. On update, the ' +
|
|
84
|
+
'full list as {id, label}: keep an option\'s id to keep the picks made for it; leave id ' +
|
|
85
|
+
'off to add one; omit an option to remove it (create, update)',
|
|
86
|
+
},
|
|
87
|
+
requestedFrom: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
items: { type: 'string' },
|
|
90
|
+
description: 'User ids of the people whose view is wanted. They are notified (on update, ' +
|
|
91
|
+
'only the newly added ones) (create, update)',
|
|
92
|
+
},
|
|
93
|
+
choiceId: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'An option id from the decision\'s choices, e.g. "c2" (add_input: your pick, ' +
|
|
96
|
+
'omit for "none of these" and say why in reason; decide: the chosen option)',
|
|
97
|
+
},
|
|
98
|
+
reason: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'One line on why you picked it (add_input)',
|
|
101
|
+
},
|
|
102
|
+
rejectedReasons: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
additionalProperties: { type: 'string' },
|
|
105
|
+
description: 'Why each option was turned down, as {choiceId: reason} (decide)',
|
|
37
106
|
},
|
|
38
107
|
// --- Identifiers ---
|
|
39
108
|
organizationId: {
|
|
@@ -74,7 +143,9 @@ export const DECISION_TOOLS = [
|
|
|
74
143
|
status: {
|
|
75
144
|
type: 'string',
|
|
76
145
|
enum: ['proposed', 'accepted', 'rejected', 'superseded', 'deprecated'],
|
|
77
|
-
description: 'Decision lifecycle status (default: "proposed") (create, update)'
|
|
146
|
+
description: 'Decision lifecycle status (default: "proposed") (create, update). For decide: ' +
|
|
147
|
+
'"accepted" (default) or "rejected" (none of the options). A decision to make on an epic ' +
|
|
148
|
+
'cannot change status through update; use decide.',
|
|
78
149
|
},
|
|
79
150
|
projectId: {
|
|
80
151
|
type: 'string',
|
|
@@ -95,7 +166,8 @@ export const DECISION_TOOLS = [
|
|
|
95
166
|
// --- promote_from_knowledge fields ---
|
|
96
167
|
taskId: {
|
|
97
168
|
type: 'string',
|
|
98
|
-
description: 'Task whose knowledge item to promote (required for promote_from_knowledge)'
|
|
169
|
+
description: 'Task whose knowledge item to promote (required for promote_from_knowledge), ' +
|
|
170
|
+
'or the task to hold back / release (hold_task, release_task)',
|
|
99
171
|
},
|
|
100
172
|
knowledgeId: {
|
|
101
173
|
type: 'string',
|
|
@@ -121,8 +193,10 @@ export const DECISION_TOOLS = [
|
|
|
121
193
|
name: 'get_decision',
|
|
122
194
|
description: 'Retrieve a single Decision, list an organization\'s decisions, or list the decisions ' +
|
|
123
195
|
'linked to a given entity (e.g. all decisions on a feature). Provide decisionId for a single ' +
|
|
124
|
-
'lookup; provide linkedType + linkedId to list decisions linked to that entity;
|
|
125
|
-
'
|
|
196
|
+
'lookup; provide linkedType + linkedId to list decisions linked to that entity; provide ' +
|
|
197
|
+
'epicId to list the decisions to make on an epic (open ones first, each with everyone\'s ' +
|
|
198
|
+
'picks and the tasks it holds back; add status "proposed" for only the open ones); ' +
|
|
199
|
+
'otherwise provide organizationId to list.',
|
|
126
200
|
inputSchema: {
|
|
127
201
|
type: 'object',
|
|
128
202
|
properties: {
|
|
@@ -132,7 +206,11 @@ export const DECISION_TOOLS = [
|
|
|
132
206
|
},
|
|
133
207
|
organizationId: {
|
|
134
208
|
type: 'string',
|
|
135
|
-
description: 'Organization ID (required for list, unless using linkedType + linkedId)',
|
|
209
|
+
description: 'Organization ID (required for list, unless using linkedType + linkedId or epicId)',
|
|
210
|
+
},
|
|
211
|
+
epicId: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
description: 'List the decisions to make on this epic, with picks and held tasks',
|
|
136
214
|
},
|
|
137
215
|
linkedType: {
|
|
138
216
|
type: 'string',
|
package/tools/epics.js
CHANGED
|
@@ -15,6 +15,28 @@
|
|
|
15
15
|
import { LINKS_ARRAY_SCHEMA, RELATED_ITEM_SCHEMA } from './link-params.js';
|
|
16
16
|
import { TASK_ITEM_PROPERTIES } from './task-item-schema.js';
|
|
17
17
|
|
|
18
|
+
// A link staged on a plan or planned task before it exists (#2199, #2752).
|
|
19
|
+
// Mirrors the desktop LinkDraft shape so a plan an AI saves back keeps the
|
|
20
|
+
// links the desktop staged.
|
|
21
|
+
const PLAN_LINKS_SCHEMA = {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
targetType: { type: 'string' },
|
|
27
|
+
targetId: { type: 'string' },
|
|
28
|
+
title: { type: 'string' },
|
|
29
|
+
source: { type: 'string', enum: ['deterministic', 'suggested', 'manual'] },
|
|
30
|
+
accepted: { type: 'boolean', description: 'false only for a suggestion that was turned down' },
|
|
31
|
+
rule: { type: 'string' },
|
|
32
|
+
confidence: { type: 'number' },
|
|
33
|
+
suggestionId: { type: 'string' },
|
|
34
|
+
matchedPaths: { type: 'array', items: { type: 'string' } },
|
|
35
|
+
},
|
|
36
|
+
required: ['targetType', 'targetId'],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
18
40
|
export const EPIC_TOOLS = [
|
|
19
41
|
{
|
|
20
42
|
name: 'manage_epic',
|
|
@@ -259,4 +281,213 @@ export const EPIC_TOOLS = [
|
|
|
259
281
|
},
|
|
260
282
|
},
|
|
261
283
|
},
|
|
284
|
+
{
|
|
285
|
+
name: 'get_epic_plan',
|
|
286
|
+
description: 'Read an epic\'s plan (E-259): the planned tasks, notes, status, staged links and any ' +
|
|
287
|
+
'open planner questions, with ' +
|
|
288
|
+
'`currentRevision`, the version number you must send back to `update_epic_plan`. ' +
|
|
289
|
+
'Several people and their AIs can work on one plan, so read it right before you change it. ' +
|
|
290
|
+
'Pass `includeHistory` to see who changed what, in plain sentences, and `revision` to read an older version.',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
295
|
+
revision: { type: 'number', description: 'Read this saved version instead of the current one' },
|
|
296
|
+
includeHistory: {
|
|
297
|
+
type: 'boolean',
|
|
298
|
+
description: 'Also return recent versions: who saved each, which AI, and what changed',
|
|
299
|
+
},
|
|
300
|
+
historyLimit: { type: 'number', description: 'How many versions of history to return (default 20)' },
|
|
301
|
+
},
|
|
302
|
+
required: ['epicId'],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'update_epic_plan',
|
|
307
|
+
description: 'Save an epic\'s plan as a new version (E-259). Send the WHOLE plan, changed where you ' +
|
|
308
|
+
'mean to change it, plus `baseRevision`: the `currentRevision` you read with `get_epic_plan` ' +
|
|
309
|
+
'(0 when the epic has no plan). Keep each planned task\'s `id` so the change is matched to the right task. ' +
|
|
310
|
+
'If someone else saved since you read it, nothing is written and you get `PLAN_CONFLICT` with the ' +
|
|
311
|
+
'current plan and what changed — apply your change to THAT plan and save again with its revision. ' +
|
|
312
|
+
'Never resend your old copy: that erases their work. ' +
|
|
313
|
+
'Keep plans simple and readable by anyone: a plain title and one line on why for each task. ' +
|
|
314
|
+
'Only the epic\'s owner, its creator or an organization admin can save the plan. If you are ' +
|
|
315
|
+
'refused, do not give up: send the SAME plan to manage_plan_proposal action "propose" and the ' +
|
|
316
|
+
'owner can accept your changes one at a time.',
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
321
|
+
baseRevision: {
|
|
322
|
+
type: 'number',
|
|
323
|
+
description: 'The currentRevision you started from (required; 0 for a new plan)',
|
|
324
|
+
},
|
|
325
|
+
plan: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
description: 'The full plan. Fields not listed here (conversation, targetFeatureId, …) ' +
|
|
328
|
+
'are kept only if you send them back.',
|
|
329
|
+
properties: {
|
|
330
|
+
notes: { type: 'string', description: 'Assumptions, risks and scope notes' },
|
|
331
|
+
status: { type: 'string', enum: ['draft', 'approved'], description: 'draft (default) or approved' },
|
|
332
|
+
proposedTasks: {
|
|
333
|
+
type: 'array',
|
|
334
|
+
items: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: {
|
|
337
|
+
id: { type: 'string', description: 'Keep the id from get_epic_plan; omit for a new task' },
|
|
338
|
+
title: { type: 'string' },
|
|
339
|
+
workType: { type: 'string' },
|
|
340
|
+
description: { type: 'string' },
|
|
341
|
+
steps: { type: 'array', items: { type: 'string' } },
|
|
342
|
+
rationale: { type: 'string', description: 'One line on why this task exists' },
|
|
343
|
+
dependsOnIndices: { type: 'array', items: { type: 'number' } },
|
|
344
|
+
needsHumanGate: { type: 'boolean' },
|
|
345
|
+
links: {
|
|
346
|
+
...PLAN_LINKS_SCHEMA,
|
|
347
|
+
description: 'Links to create with this task when the plan is approved. Send back ' +
|
|
348
|
+
'what get_epic_plan returned',
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
required: ['title'],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
links: {
|
|
355
|
+
...PLAN_LINKS_SCHEMA,
|
|
356
|
+
description: 'Links for the epic, applied when the plan is approved. Send back what ' +
|
|
357
|
+
'get_epic_plan returned',
|
|
358
|
+
},
|
|
359
|
+
questions: {
|
|
360
|
+
type: 'array',
|
|
361
|
+
description: 'The planner\'s open clarifying questions, kept as returned by get_epic_plan. ' +
|
|
362
|
+
'A choice that needs several people\'s view belongs on the epic as a decision to make ' +
|
|
363
|
+
'(manage_decision with epicId) instead.',
|
|
364
|
+
items: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
id: { type: 'string' },
|
|
368
|
+
question: { type: 'string' },
|
|
369
|
+
header: { type: 'string' },
|
|
370
|
+
options: {
|
|
371
|
+
type: 'array',
|
|
372
|
+
items: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: { label: { type: 'string' }, description: { type: 'string' } },
|
|
375
|
+
required: ['label'],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
multiSelect: { type: 'boolean' },
|
|
379
|
+
},
|
|
380
|
+
required: ['question'],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
required: ['epicId', 'baseRevision', 'plan'],
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'get_epic_activity',
|
|
391
|
+
description: 'Catch me up on an epic (E-259): what changed since YOU last looked. Returns `summary`, ' +
|
|
392
|
+
'plain sentences you can relay to your person as-is, most important first: decisions waiting on ' +
|
|
393
|
+
'their view, comments that mention or reply to them, decisions made, new plan versions (who ' +
|
|
394
|
+
'changed what, and which AI did it for them), and tasks added, started, finished or blocked. ' +
|
|
395
|
+
'The details are alongside. Their own changes are left out. Call it when you start or resume ' +
|
|
396
|
+
'work on an epic other people also work on, and before changing its plan. By default this also ' +
|
|
397
|
+
'marks the epic as caught up, so the next call shows only newer changes; pass markSeen:false to ' +
|
|
398
|
+
'look without that.',
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
403
|
+
since: {
|
|
404
|
+
type: 'string',
|
|
405
|
+
description: 'Show changes since this time (RFC 3339, e.g. 2026-09-19T14:00:00Z) instead of ' +
|
|
406
|
+
'since the last catch-up. The first catch-up on an epic covers the last 7 days.',
|
|
407
|
+
},
|
|
408
|
+
markSeen: {
|
|
409
|
+
type: 'boolean',
|
|
410
|
+
description: 'Mark the epic as caught up after answering (default true)',
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
required: ['epicId'],
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'list_epic_comments',
|
|
418
|
+
description: 'Read an epic\'s discussion (E-259), oldest first. Each comment says who wrote it and, ' +
|
|
419
|
+
'when an AI wrote it for them, which AI (`agentName`). Replies carry `parentId`. ' +
|
|
420
|
+
'Read this before planning or changing a shared epic: other people\'s questions and objections live here.',
|
|
421
|
+
inputSchema: {
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: {
|
|
424
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
425
|
+
limit: { type: 'number', description: 'Maximum comments to return (default 100, max 500)' },
|
|
426
|
+
},
|
|
427
|
+
required: ['epicId'],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: 'add_epic_comment',
|
|
432
|
+
description: 'Post to an epic\'s discussion (E-259), or reply to a comment with `parentId`. ' +
|
|
433
|
+
'Posted as the person whose key you use, marked as written by you. ' +
|
|
434
|
+
'Mentioned people, the author you reply to and everyone following the epic are notified, ' +
|
|
435
|
+
'and posting makes that person follow it. Write plainly: one point per comment, readable by anyone.',
|
|
436
|
+
inputSchema: {
|
|
437
|
+
type: 'object',
|
|
438
|
+
properties: {
|
|
439
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
440
|
+
content: { type: 'string', description: 'The comment (markdown)' },
|
|
441
|
+
parentId: { type: 'string', description: 'Reply to this comment' },
|
|
442
|
+
mentions: { type: 'array', items: { type: 'string' }, description: 'User IDs to notify' },
|
|
443
|
+
},
|
|
444
|
+
required: ['epicId', 'content'],
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: 'manage_plan_proposal',
|
|
449
|
+
description: 'Suggest a change to an epic\'s plan when you cannot save it yourself, and answer ' +
|
|
450
|
+
'suggestions on plans you own (E-259).\n\n' +
|
|
451
|
+
'propose: send the plan you WANT, exactly as you would to update_epic_plan. The server works out ' +
|
|
452
|
+
'what you changed and lists it as separate changes, each with a plain sentence, so the owner can ' +
|
|
453
|
+
'take some and leave others. Nothing changes until they do.\n' +
|
|
454
|
+
'list: what is waiting on an epic. Open ones come first, and each change that no longer fits the ' +
|
|
455
|
+
'current plan is flagged with the reason.\n' +
|
|
456
|
+
'review (owner, creator or org admin only): `accept` and `reject` name changes by their op id. ' +
|
|
457
|
+
'A change you name in neither is left for later and the proposal stays open. A change whose task ' +
|
|
458
|
+
'someone has since removed is reported back as stale rather than quietly reapplied.\n' +
|
|
459
|
+
'withdraw: take back a proposal you made.',
|
|
460
|
+
inputSchema: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
properties: {
|
|
463
|
+
action: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
enum: ['propose', 'list', 'get', 'review', 'withdraw'],
|
|
466
|
+
description: 'What to do',
|
|
467
|
+
},
|
|
468
|
+
epicId: { type: 'string', description: 'The epic (required for propose and list)' },
|
|
469
|
+
proposalId: { type: 'string', description: 'The proposal (required for get, review and withdraw)' },
|
|
470
|
+
plan: {
|
|
471
|
+
type: 'object',
|
|
472
|
+
description: 'propose: the whole plan you want, same shape as update_epic_plan. Keep each ' +
|
|
473
|
+
'planned task\'s id so your change is matched to the right task.',
|
|
474
|
+
},
|
|
475
|
+
title: { type: 'string', description: 'propose: a short name for the proposal' },
|
|
476
|
+
rationale: { type: 'string', description: 'propose: why, in a sentence or two, in plain language' },
|
|
477
|
+
accept: {
|
|
478
|
+
type: 'array',
|
|
479
|
+
items: { type: 'string' },
|
|
480
|
+
description: 'review: op ids of the changes you are taking',
|
|
481
|
+
},
|
|
482
|
+
reject: {
|
|
483
|
+
type: 'array',
|
|
484
|
+
items: { type: 'string' },
|
|
485
|
+
description: 'review: op ids of the changes you are turning down',
|
|
486
|
+
},
|
|
487
|
+
note: { type: 'string', description: 'review: what you want to say back, in your own words' },
|
|
488
|
+
status: { type: 'string', description: 'list: only proposals in this state (default: all)' },
|
|
489
|
+
},
|
|
490
|
+
required: ['action'],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
262
493
|
];
|
package/tools/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { FEATURE_TOOLS } from './features.js';
|
|
|
16
16
|
import { DECISION_TOOLS } from './decisions.js';
|
|
17
17
|
import { DESIGN_TOOLS } from './designs.js';
|
|
18
18
|
import { CATALOG_TOOLS } from './catalogs.js';
|
|
19
|
+
import { UNMAPPED_PATH_TOOLS } from './unmapped-paths.js';
|
|
19
20
|
import { FEATURE_FLAG_TOOLS } from './feature-flags.js';
|
|
20
21
|
import { TASK_TOOLS } from './tasks.js';
|
|
21
22
|
import { DOCUMENT_TOOLS } from './documents.js';
|
|
@@ -53,6 +54,7 @@ export const TOOLS = [
|
|
|
53
54
|
...DECISION_TOOLS,
|
|
54
55
|
...DESIGN_TOOLS,
|
|
55
56
|
...CATALOG_TOOLS,
|
|
57
|
+
...UNMAPPED_PATH_TOOLS,
|
|
56
58
|
...FEATURE_FLAG_TOOLS,
|
|
57
59
|
...TASK_TOOLS,
|
|
58
60
|
...RECURRING_TASK_TOOLS,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unmapped code path tools (E-258 #2756)
|
|
3
|
+
*
|
|
4
|
+
* When Components were retired, every component source path that belonged to no
|
|
5
|
+
* feature was recorded for review instead of being dropped. Each row is one
|
|
6
|
+
* question — "which capability owns this code?" — and until it is answered, work
|
|
7
|
+
* touching those files auto-links to nothing.
|
|
8
|
+
*
|
|
9
|
+
* The list lived only on the project Features page, so an agent asked to tidy it
|
|
10
|
+
* could set feature paths but never see or close the rows. These two tools make
|
|
11
|
+
* that loop completable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const UNMAPPED_PATH_TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'list_unmapped_paths',
|
|
17
|
+
description: 'List a project\'s unmapped code paths — former component paths that no feature owns ' +
|
|
18
|
+
'(E-258). Each row carries the source path and the component it came from. A pending row means ' +
|
|
19
|
+
'work touching those files links to no capability, so this is the backlog to work through when ' +
|
|
20
|
+
'a project\'s features are missing code ownership.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
projectId: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Project ID (required)',
|
|
27
|
+
},
|
|
28
|
+
status: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
enum: ['pending', 'assigned', 'dismissed', 'all'],
|
|
31
|
+
description: 'Which rows to return (default "pending")',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ['projectId'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'resolve_unmapped',
|
|
39
|
+
description: 'Resolve unmapped code paths (E-258). "reconcile" is the one to reach for first: it ' +
|
|
40
|
+
'closes every pending row a feature has SINCE been given a path for, so the usual flow is to set ' +
|
|
41
|
+
'ownership with manage_feature action:"paths" and then reconcile, rather than answering rows one ' +
|
|
42
|
+
'by one. A path several features own stays pending — shared ownership is a judgement, and those ' +
|
|
43
|
+
'only ever produce link suggestions anyway.\n\n' +
|
|
44
|
+
'"assign" gives ONE row to a feature (adding that feature path, with the usual scope check), and ' +
|
|
45
|
+
'"dismiss" records that the path belongs to no capability — the right answer for shared plumbing.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
action: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
enum: ['reconcile', 'assign', 'dismiss'],
|
|
52
|
+
description: 'reconcile: close every pending row an existing feature path already covers. ' +
|
|
53
|
+
'assign: give one row (pathId) to featureId. dismiss: mark one row (pathId) as owned by nobody.',
|
|
54
|
+
},
|
|
55
|
+
projectId: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Project ID (required for every action)',
|
|
58
|
+
},
|
|
59
|
+
pathId: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Unmapped path ID, from list_unmapped_paths (required for assign and dismiss)',
|
|
62
|
+
},
|
|
63
|
+
featureId: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Feature to give the path to (required for assign). It must span the project, ' +
|
|
66
|
+
'or be org-wide.',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['action', 'projectId'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
];
|