@ezmodo/mcp-server 0.13.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/README.md +305 -0
- package/config/development.js +20 -0
- package/config/endpoint-map.js +351 -0
- package/config/index.js +34 -0
- package/config/production.js +18 -0
- package/config/staging.js +18 -0
- package/handlers/access.js +141 -0
- package/handlers/activity.js +112 -0
- package/handlers/agents.js +95 -0
- package/handlers/ai-intelligence.js +55 -0
- package/handlers/attachments.js +30 -0
- package/handlers/catalogs.js +169 -0
- package/handlers/components.js +282 -0
- package/handlers/context-manifest.js +1150 -0
- package/handlers/decisions.js +114 -0
- package/handlers/designs.js +118 -0
- package/handlers/documents.js +227 -0
- package/handlers/entities.js +95 -0
- package/handlers/epics.js +190 -0
- package/handlers/facts.js +62 -0
- package/handlers/feature-flags.js +142 -0
- package/handlers/features.js +137 -0
- package/handlers/folders.js +127 -0
- package/handlers/git-context.js +917 -0
- package/handlers/github.js +72 -0
- package/handlers/graph.js +23 -0
- package/handlers/index.js +205 -0
- package/handlers/links.js +156 -0
- package/handlers/milestones.js +131 -0
- package/handlers/organizations.js +14 -0
- package/handlers/projects.js +122 -0
- package/handlers/recurring-tasks.js +33 -0
- package/handlers/tags.js +124 -0
- package/handlers/tasks.js +561 -0
- package/handlers/testing.js +116 -0
- package/handlers/todos.js +43 -0
- package/handlers/watchers.js +54 -0
- package/handlers/work-templates.js +32 -0
- package/index.js +175 -0
- package/lib/active-session.js +86 -0
- package/lib/auto-assign.js +93 -0
- package/lib/autolink.js +176 -0
- package/lib/changed-files.js +22 -0
- package/lib/env.js +45 -0
- package/lib/git-helpers.js +553 -0
- package/lib/git-utils.js +73 -0
- package/lib/http-client.js +164 -0
- package/lib/links-at-create.js +94 -0
- package/lib/local-cache.js +140 -0
- package/lib/logger.js +109 -0
- package/lib/manifest-loader.js +182 -0
- package/lib/manifest-query.js +686 -0
- package/lib/repo-config-dir.js +118 -0
- package/lib/version.js +10 -0
- package/lib/web-url.js +69 -0
- package/lib/worktree-tools.js +950 -0
- package/package.json +62 -0
- package/prompts/ai-workflow-automation.js +96 -0
- package/prompts/index.js +39 -0
- package/prompts/zephly-usage-guide-content.txt +631 -0
- package/prompts/zephly-usage-guide.js +119 -0
- package/tools/access-entity-types.js +28 -0
- package/tools/access.js +152 -0
- package/tools/activity.js +38 -0
- package/tools/agents.js +208 -0
- package/tools/ai-intelligence.js +111 -0
- package/tools/attachments.js +92 -0
- package/tools/catalogs.js +341 -0
- package/tools/components.js +249 -0
- package/tools/context-manifest.js +236 -0
- package/tools/decisions.js +168 -0
- package/tools/designs.js +222 -0
- package/tools/documents.js +287 -0
- package/tools/entities.js +223 -0
- package/tools/epics.js +267 -0
- package/tools/facts.js +70 -0
- package/tools/feature-flags.js +300 -0
- package/tools/features.js +246 -0
- package/tools/folders.js +122 -0
- package/tools/git-context.js +109 -0
- package/tools/github.js +172 -0
- package/tools/graph.js +70 -0
- package/tools/index.js +77 -0
- package/tools/link-params.js +93 -0
- package/tools/linkable-types.js +36 -0
- package/tools/links.js +199 -0
- package/tools/milestones.js +176 -0
- package/tools/organizations.js +23 -0
- package/tools/projects.js +172 -0
- package/tools/recurring-tasks.js +115 -0
- package/tools/tags.js +219 -0
- package/tools/task-item-schema.js +57 -0
- package/tools/task-type.js +33 -0
- package/tools/tasks.js +680 -0
- package/tools/testing.js +344 -0
- package/tools/todos.js +69 -0
- package/tools/watchers.js +81 -0
- package/tools/work-templates.js +96 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Handlers
|
|
3
|
+
* Handler functions for task-related MCP tools
|
|
4
|
+
*
|
|
5
|
+
* Project-First Hierarchy: Tasks belong to projects (required), with optional epic grouping
|
|
6
|
+
*
|
|
7
|
+
* Component assignment: Each task should belong to exactly one component.
|
|
8
|
+
* AI agents must explicitly provide componentId (from get_current_project_context).
|
|
9
|
+
* Tags are auto-assigned based on content analysis against the local project cache.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { previewEntityLinks, partitionProposals, attachSuggestionIds } from '../lib/autolink.js';
|
|
13
|
+
import { callZephlyAPI } from '../lib/http-client.js';
|
|
14
|
+
import { resolveTaskAutoAssign } from '../lib/auto-assign.js';
|
|
15
|
+
import { buildTaskUrl } from '../lib/web-url.js';
|
|
16
|
+
import { getLogger } from '../lib/logger.js';
|
|
17
|
+
import { writeActiveSession, clearActiveSession } from '../lib/active-session.js';
|
|
18
|
+
import { attachLinks } from '../lib/links-at-create.js';
|
|
19
|
+
import { getContext } from './context-manifest.js';
|
|
20
|
+
import { getCommitFiles, getRepositoryRoot } from '../lib/git-helpers.js';
|
|
21
|
+
import { normalizeChangedFiles } from '../lib/changed-files.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply tags to a newly created entity via bulkTagEntities.
|
|
25
|
+
* Non-fatal — logs errors but doesn't throw.
|
|
26
|
+
*/
|
|
27
|
+
async function applyAutoTags(organizationId, entityType, entityId, tagIds) {
|
|
28
|
+
if (!organizationId || !tagIds?.length || !entityId) return;
|
|
29
|
+
try {
|
|
30
|
+
await callZephlyAPI('mcpBulkTagEntities', {
|
|
31
|
+
organizationId,
|
|
32
|
+
tagIds,
|
|
33
|
+
entities: [{ entityType, entityId }],
|
|
34
|
+
operation: 'add',
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
getLogger().warn('Auto-tag failed', { entityType, entityId, error: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dispatch manage_task actions to the appropriate handler
|
|
43
|
+
*/
|
|
44
|
+
export async function manageTask(args) {
|
|
45
|
+
const { action, ...params } = args;
|
|
46
|
+
switch (action) {
|
|
47
|
+
case 'create': return createTask(params);
|
|
48
|
+
case 'update': return updateTask(params);
|
|
49
|
+
case 'complete': return completeTask(params);
|
|
50
|
+
case 'defer': return deferTask(params);
|
|
51
|
+
case 'link_commit': return linkCommitToTask(params);
|
|
52
|
+
case 'unlink_commit': return unlinkCommitFromTask(params);
|
|
53
|
+
case 'get_commits': return getTaskCommits(params);
|
|
54
|
+
case 'generate_how_it_works': return generateTaskHowItWorks(params);
|
|
55
|
+
case 'apply_how_it_works': return applyTaskHowItWorks(params);
|
|
56
|
+
default: throw new Error(`Unknown action: ${action}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bridge user terminology ("dashboard") to code locations by searching the
|
|
62
|
+
* manifest for the task's own words. Best-effort: returns null when there is
|
|
63
|
+
* too little to go on or the search fails.
|
|
64
|
+
*/
|
|
65
|
+
async function resolveCodeContext({ projectId, title, description }) {
|
|
66
|
+
const searchQuery = [title, description].filter(Boolean).join(' ');
|
|
67
|
+
if (searchQuery.length <= 5) return null;
|
|
68
|
+
try {
|
|
69
|
+
const contextResult = await getContext({ projectId, query: searchQuery, limit: 10 });
|
|
70
|
+
const topFiles = (contextResult?.topResults || []).slice(0, 5);
|
|
71
|
+
if (topFiles.length === 0) return null;
|
|
72
|
+
return {
|
|
73
|
+
files: topFiles.map((f) => f.path),
|
|
74
|
+
summary: topFiles.map((f) => `- ${f.path}: ${f.summary || ''}`).join('\n'),
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function createTask(args) {
|
|
82
|
+
// `links` is applied by the MCP layer after the task exists (E-225), so it is
|
|
83
|
+
// kept out of the create payload.
|
|
84
|
+
const { links, changedFiles, autolink = true, ...createArgs } = args;
|
|
85
|
+
const { projectId, title, description } = createArgs;
|
|
86
|
+
|
|
87
|
+
// Resolve the task's code context BEFORE creating it, so the files go in with
|
|
88
|
+
// the create rather than being patched on afterwards (E-225).
|
|
89
|
+
//
|
|
90
|
+
// This used to run after create as one getContext plus up to six sequential
|
|
91
|
+
// mcpUpdateTask calls — one for knowledge, one per file. Beyond the round
|
|
92
|
+
// trips, each addLinkedFile independently triggered the autolink engine, so a
|
|
93
|
+
// single task create fanned out into five redundant derivations. Folding the
|
|
94
|
+
// files into the create means one trigger with the complete set, which is
|
|
95
|
+
// also what lets the engine's fan-out collapse see the whole change at once.
|
|
96
|
+
let autoContext = null;
|
|
97
|
+
const explicitFiles = normalizeChangedFiles(changedFiles, createArgs.linkedFiles);
|
|
98
|
+
if (explicitFiles.length > 0) {
|
|
99
|
+
createArgs.linkedFiles = explicitFiles;
|
|
100
|
+
} else if (autolink && projectId) {
|
|
101
|
+
autoContext = await resolveCodeContext({ projectId, title, description });
|
|
102
|
+
if (autoContext) {
|
|
103
|
+
createArgs.linkedFiles = autoContext.files.map((path) => ({ path, source: 'mcp' }));
|
|
104
|
+
createArgs.knowledge = [
|
|
105
|
+
...(createArgs.knowledge || []),
|
|
106
|
+
{
|
|
107
|
+
type: 'reference',
|
|
108
|
+
content: `Auto-resolved code context:\n${autoContext.summary}`,
|
|
109
|
+
tags: ['auto-context', 'code-reference'],
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create the task (componentId should be explicitly provided by the agent)
|
|
116
|
+
const result = await callZephlyAPI('mcpCreateTask', createArgs);
|
|
117
|
+
|
|
118
|
+
// If the milestone is frozen and the operation was blocked, return guidance
|
|
119
|
+
if (result?.blocked) {
|
|
120
|
+
return {
|
|
121
|
+
blocked: true,
|
|
122
|
+
freezeType: result.freezeType,
|
|
123
|
+
milestoneId: result.milestoneId,
|
|
124
|
+
milestoneName: result.milestoneName,
|
|
125
|
+
message: `Milestone Frozen (${result.milestoneName || 'unknown'}, ${result.freezeType}): ${result.reason || 'No reason provided'}`,
|
|
126
|
+
allowedTaskTypes: result.allowedTaskTypes || [],
|
|
127
|
+
suggestion: result.suggestion,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auto-apply tags (non-fatal)
|
|
132
|
+
const autoAssign = await resolveTaskAutoAssign(projectId, title, description);
|
|
133
|
+
if (autoAssign?.matchedTags?.length && result?.taskId) {
|
|
134
|
+
await applyAutoTags(
|
|
135
|
+
autoAssign.organizationId,
|
|
136
|
+
'task',
|
|
137
|
+
result.taskId,
|
|
138
|
+
autoAssign.matchedTags.map((t) => t.id),
|
|
139
|
+
);
|
|
140
|
+
result.autoAssigned = {
|
|
141
|
+
tags: autoAssign.matchedTags.map((t) => t.name),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (autoContext) {
|
|
146
|
+
result.autoContext = {
|
|
147
|
+
filesFound: autoContext.files.length,
|
|
148
|
+
topFiles: autoContext.files,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Surface what the engine did and what it wants confirmed, in the SAME
|
|
153
|
+
// response that created the task — so an agent can resolve its suggestions in
|
|
154
|
+
// the same turn instead of discovering them on a later read it may never do.
|
|
155
|
+
let previewed = [];
|
|
156
|
+
if (autolink && result?.taskId && projectId) {
|
|
157
|
+
const { proposals } = await previewEntityLinks({
|
|
158
|
+
projectId,
|
|
159
|
+
subjectType: 'task',
|
|
160
|
+
subjectId: result.taskId,
|
|
161
|
+
paths: (createArgs.linkedFiles || []).map((f) => f.path),
|
|
162
|
+
componentId: createArgs.componentId,
|
|
163
|
+
epicId: createArgs.epicId,
|
|
164
|
+
});
|
|
165
|
+
const { autoLinked, linkSuggestions } = partitionProposals(proposals);
|
|
166
|
+
if (autoLinked.length > 0) result.autoLinked = autoLinked;
|
|
167
|
+
previewed = linkSuggestions;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Suggestions need the id resolve_link_suggestions takes; the preview is
|
|
171
|
+
// read-only so its proposals have none. Read them back off the queue the
|
|
172
|
+
// engine writes to (#2297).
|
|
173
|
+
//
|
|
174
|
+
// Deliberately outside the `autolink` guard above: that flag turns off the
|
|
175
|
+
// codebase search this handler does, not the server's own linking. Rows get
|
|
176
|
+
// queued either way, and a task created with autolink:false would otherwise
|
|
177
|
+
// never learn it had suggestions waiting.
|
|
178
|
+
if (result?.taskId) {
|
|
179
|
+
const resolved = await attachSuggestionIds({
|
|
180
|
+
subjectType: 'task',
|
|
181
|
+
subjectId: result.taskId,
|
|
182
|
+
linkSuggestions: previewed,
|
|
183
|
+
});
|
|
184
|
+
if (resolved.linkSuggestions.length > 0) {
|
|
185
|
+
result.linkSuggestions = resolved.linkSuggestions;
|
|
186
|
+
result.linkSuggestionsNote = resolved.partial
|
|
187
|
+
? 'Resolve these with resolve_link_suggestions using each suggestionId. Some have no '
|
|
188
|
+
+ 'suggestionId yet and more may still be queued — call list_agent_suggestions '
|
|
189
|
+
+ `action:"link" entityType:"task" entityId:"${result.taskId}" for the authoritative set.`
|
|
190
|
+
: 'Resolve these with resolve_link_suggestions using each suggestionId.';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// If no component was named, include available components as a hint.
|
|
195
|
+
//
|
|
196
|
+
// Tests the EFFECTIVE set, not just the deprecated singular. Checking only
|
|
197
|
+
// `componentId` meant a create that correctly used `componentIds` was told it
|
|
198
|
+
// had named no component — while the links had in fact been written — so
|
|
199
|
+
// agents kept issuing a redundant follow-up update to fix nothing (#2429).
|
|
200
|
+
const namedComponents = Boolean(args.componentId)
|
|
201
|
+
|| (Array.isArray(args.componentIds) && args.componentIds.some(Boolean));
|
|
202
|
+
if (!namedComponents && autoAssign?.availableComponents?.length) {
|
|
203
|
+
result.warning = 'Task created without a component. Please provide componentIds '
|
|
204
|
+
+ 'for better organization. Use get_current_project_context() to see available components.';
|
|
205
|
+
result.availableComponents = autoAssign.availableComponents;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Attach create-time links (E-225) — best effort, never fails the create.
|
|
209
|
+
await attachLinks(result, {
|
|
210
|
+
sourceType: 'task',
|
|
211
|
+
sourceId: result?.taskId,
|
|
212
|
+
links: args.links,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Enrich with web URL
|
|
216
|
+
const webUrl = await buildTaskUrl(result?.taskNumber);
|
|
217
|
+
if (webUrl) result.webUrl = webUrl;
|
|
218
|
+
|
|
219
|
+
// Write active session file if task starts in_progress
|
|
220
|
+
if (args.status === 'in_progress' && result?.taskId) {
|
|
221
|
+
await writeActiveSession({
|
|
222
|
+
taskId: result.taskId,
|
|
223
|
+
taskNumber: result.taskNumber,
|
|
224
|
+
title: args.title,
|
|
225
|
+
epicId: args.epicId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build a task description enriched with drift evidence (branch + changed
|
|
234
|
+
* files), mirroring the desktop quick-create dialog so retroactively-captured
|
|
235
|
+
* untracked work carries the same context.
|
|
236
|
+
*/
|
|
237
|
+
function buildUntrackedDescription({ description, branch, changedFiles }) {
|
|
238
|
+
const parts = [];
|
|
239
|
+
if (description && description.trim()) parts.push(description.trim());
|
|
240
|
+
|
|
241
|
+
const evidence = [];
|
|
242
|
+
if (branch) evidence.push(`**Branch:** ${branch}`);
|
|
243
|
+
if (Array.isArray(changedFiles) && changedFiles.length > 0) {
|
|
244
|
+
evidence.push(`**Changed files (${changedFiles.length}):**`);
|
|
245
|
+
for (const f of changedFiles) evidence.push(`- ${f}`);
|
|
246
|
+
}
|
|
247
|
+
if (evidence.length > 0) {
|
|
248
|
+
parts.push(`_Captured as untracked work._\n\n${evidence.join('\n')}`);
|
|
249
|
+
}
|
|
250
|
+
return parts.join('\n\n');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Retroactively report work done without a task (E-200 #958). This is the
|
|
255
|
+
* agent-side counterpart to the desktop quick-create dialog (#955): when Claude
|
|
256
|
+
* realizes mid-conversation that it made changes without a tracked task (e.g. a
|
|
257
|
+
* "just fix this real quick" request), it calls this to capture the work.
|
|
258
|
+
*
|
|
259
|
+
* Creates a task classified by `origin` (default "untracked"), enriches the
|
|
260
|
+
* description with the branch + changed files, links the discovery chain when
|
|
261
|
+
* provided, and starts it in_progress so it becomes the active task — which
|
|
262
|
+
* also writes active-session.json for the desktop app to pick up (via the
|
|
263
|
+
* shared createTask path). Returns the created task so the agent can continue
|
|
264
|
+
* tracking against it.
|
|
265
|
+
*/
|
|
266
|
+
/**
|
|
267
|
+
* Create many tasks in one API call (E-227 #2225).
|
|
268
|
+
*
|
|
269
|
+
* The win is per-REQUEST cost, not per-task work: N individual creates pay N
|
|
270
|
+
* bcrypt compares, key lookups, plan checks, rate-limit counts and RLS
|
|
271
|
+
* connection acquires. One call pays each once. A burst of individual creates is
|
|
272
|
+
* what took the app down for 45 minutes on 2026-07-28.
|
|
273
|
+
*
|
|
274
|
+
* Deliberately thinner than createTask. The single-create path also resolves code
|
|
275
|
+
* context (a getContext call), applies links, and previews autolink proposals —
|
|
276
|
+
* all per task. Doing that here would reintroduce the per-item round trips this
|
|
277
|
+
* exists to remove, and turn one call into 3N. So this creates the tasks and
|
|
278
|
+
* reports what happened; enrich individually afterwards if a task needs it.
|
|
279
|
+
*
|
|
280
|
+
* Never throws on a partial failure — per-item outcomes come back in `results`
|
|
281
|
+
* so the caller can retry precisely the failures instead of re-sending a batch
|
|
282
|
+
* that would duplicate everything already created.
|
|
283
|
+
*/
|
|
284
|
+
export async function bulkCreateTasks(args) {
|
|
285
|
+
const { projectId, epicId, componentId, componentIds, tasks = [] } = args;
|
|
286
|
+
|
|
287
|
+
if (!projectId) throw new Error('projectId is required');
|
|
288
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
289
|
+
throw new Error('tasks must be a non-empty array');
|
|
290
|
+
}
|
|
291
|
+
// Checked client-side too so an oversized batch fails immediately with
|
|
292
|
+
// actionable advice rather than spending a request to be rejected.
|
|
293
|
+
if (tasks.length > 40) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`cannot create ${tasks.length} tasks in one call (limit 40) — ` +
|
|
296
|
+
'split into batches of 40 or fewer',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const result = await callZephlyAPI('mcpBulkCreateTasks', {
|
|
301
|
+
projectId,
|
|
302
|
+
epicId,
|
|
303
|
+
componentId,
|
|
304
|
+
componentIds,
|
|
305
|
+
tasks: tasks.map(({ changedFiles, linkedFiles, ...rest }) => ({
|
|
306
|
+
...rest,
|
|
307
|
+
// Accept the same two spellings as manage_task so callers do not have to
|
|
308
|
+
// remember which shape this tool wants.
|
|
309
|
+
linkedFiles: normalizeChangedFiles(changedFiles, linkedFiles),
|
|
310
|
+
})),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const created = result?.created ?? 0;
|
|
314
|
+
const failed = result?.failed ?? 0;
|
|
315
|
+
const results = result?.results ?? [];
|
|
316
|
+
|
|
317
|
+
const out = {
|
|
318
|
+
created,
|
|
319
|
+
failed,
|
|
320
|
+
results,
|
|
321
|
+
message: failed === 0
|
|
322
|
+
? `Created ${created} task${created === 1 ? '' : 's'} in one request.`
|
|
323
|
+
: `Created ${created} of ${created + failed}. ${failed} failed — retry ONLY the ` +
|
|
324
|
+
'items listed in failures; the successful tasks already exist.',
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Surface failures separately: an agent scanning a 40-entry array can miss
|
|
328
|
+
// three error fields, and re-running the whole batch is the costly mistake.
|
|
329
|
+
if (failed > 0) {
|
|
330
|
+
out.failures = results
|
|
331
|
+
.filter((r) => r.error)
|
|
332
|
+
.map((r) => ({ index: r.index, title: r.title, error: r.error }));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (created > 0) {
|
|
336
|
+
out.taskNumbers = results.filter((r) => r.taskNumber).map((r) => r.taskNumber);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function reportUntrackedWork(args) {
|
|
343
|
+
const {
|
|
344
|
+
projectId,
|
|
345
|
+
title,
|
|
346
|
+
description,
|
|
347
|
+
componentId,
|
|
348
|
+
componentIds,
|
|
349
|
+
origin = 'untracked',
|
|
350
|
+
discoveredDuringTaskId,
|
|
351
|
+
branch,
|
|
352
|
+
changedFiles,
|
|
353
|
+
epicId,
|
|
354
|
+
featureId,
|
|
355
|
+
links,
|
|
356
|
+
} = args;
|
|
357
|
+
|
|
358
|
+
if (!projectId) throw new Error('projectId is required');
|
|
359
|
+
if (!title) throw new Error('title is required');
|
|
360
|
+
|
|
361
|
+
// A featureId is just a link to the feature the work advanced — fold it into
|
|
362
|
+
// the links array so untracked work still lands on the capability map.
|
|
363
|
+
const allLinks = [
|
|
364
|
+
...(Array.isArray(links) ? links : []),
|
|
365
|
+
...(featureId ? [{ targetType: 'feature', targetId: featureId }] : []),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const createArgs = {
|
|
369
|
+
projectId,
|
|
370
|
+
title,
|
|
371
|
+
description: buildUntrackedDescription({ description, branch, changedFiles }),
|
|
372
|
+
status: 'in_progress',
|
|
373
|
+
origin,
|
|
374
|
+
...(discoveredDuringTaskId ? { discoveredDuringTaskId } : {}),
|
|
375
|
+
...(componentId ? { componentId } : {}),
|
|
376
|
+
...(Array.isArray(componentIds) && componentIds.length > 0 ? { componentIds } : {}),
|
|
377
|
+
...(epicId ? { epicId } : {}),
|
|
378
|
+
...(allLinks.length > 0 ? { links: allLinks } : {}),
|
|
379
|
+
...(Array.isArray(changedFiles) && changedFiles.length > 0
|
|
380
|
+
? { linkedFiles: changedFiles.map((path) => ({ path, source: 'mcp' })) }
|
|
381
|
+
: {}),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return createTask(createArgs);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function updateTask(args) {
|
|
388
|
+
const result = await callZephlyAPI('mcpUpdateTask', args);
|
|
389
|
+
|
|
390
|
+
// If the milestone is frozen and the operation was blocked, return guidance
|
|
391
|
+
if (result?.blocked) {
|
|
392
|
+
return {
|
|
393
|
+
blocked: true,
|
|
394
|
+
freezeType: result.freezeType,
|
|
395
|
+
milestoneId: result.milestoneId,
|
|
396
|
+
milestoneName: result.milestoneName,
|
|
397
|
+
message: `Milestone Frozen (${result.milestoneName || 'unknown'}, ${result.freezeType}): ${result.reason || 'No reason provided'}`,
|
|
398
|
+
allowedTaskTypes: result.allowedTaskTypes || [],
|
|
399
|
+
suggestion: result.suggestion,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Manage active session file based on status transitions
|
|
404
|
+
if (args.status) {
|
|
405
|
+
const endStatuses = ['completed', 'cancelled', 'in_review'];
|
|
406
|
+
if (args.status === 'in_progress') {
|
|
407
|
+
// Fetch full task data to populate session file
|
|
408
|
+
try {
|
|
409
|
+
const taskResult = await callZephlyAPI('mcpGetTask', { taskId: args.taskId });
|
|
410
|
+
if (taskResult?.task) {
|
|
411
|
+
await writeActiveSession({
|
|
412
|
+
taskId: taskResult.task.id,
|
|
413
|
+
taskNumber: taskResult.task.taskNumber,
|
|
414
|
+
title: taskResult.task.title,
|
|
415
|
+
epicId: taskResult.task.epicId,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
getLogger().warn('Failed to fetch task for session file', { error: err.message });
|
|
420
|
+
}
|
|
421
|
+
} else if (endStatuses.includes(args.status)) {
|
|
422
|
+
await clearActiveSession();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function completeTask(args) {
|
|
430
|
+
const result = await callZephlyAPI('mcpCompleteTask', args);
|
|
431
|
+
await clearActiveSession();
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function deferTask(args) {
|
|
436
|
+
// Required: taskId + reason. The API rejects on missing values but a JS-side
|
|
437
|
+
// guard surfaces a clearer error to MCP callers without a round trip.
|
|
438
|
+
if (!args.taskId) {
|
|
439
|
+
throw new Error('taskId is required for defer');
|
|
440
|
+
}
|
|
441
|
+
if (!args.reason || !args.reason.trim()) {
|
|
442
|
+
throw new Error('reason is required for defer');
|
|
443
|
+
}
|
|
444
|
+
return callZephlyAPI('mcpDeferTask', {
|
|
445
|
+
taskId: args.taskId,
|
|
446
|
+
reason: args.reason,
|
|
447
|
+
stepId: args.stepId,
|
|
448
|
+
unblockedBy: args.unblockedBy,
|
|
449
|
+
targetMilestoneId: args.targetMilestoneId,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function searchTasks(args) {
|
|
454
|
+
// Use semantic search if searchText is provided and projectId is available
|
|
455
|
+
if (args.searchText && args.projectId) {
|
|
456
|
+
try {
|
|
457
|
+
// Transform args for semantic search API
|
|
458
|
+
const semanticArgs = {
|
|
459
|
+
projectId: args.projectId,
|
|
460
|
+
query: args.searchText,
|
|
461
|
+
entityType: 'task',
|
|
462
|
+
minSimilarity: args.minSimilarity || 0.3,
|
|
463
|
+
limit: args.limit || 10,
|
|
464
|
+
// Pass through filter params
|
|
465
|
+
status: args.status,
|
|
466
|
+
priority: args.priority,
|
|
467
|
+
labels: args.labels,
|
|
468
|
+
assignees: args.assignees,
|
|
469
|
+
epicId: args.epicId,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const result = await callZephlyAPI('mcpSemanticTaskSearch', semanticArgs);
|
|
473
|
+
|
|
474
|
+
// Fall back to basic search if semantic returns no results (e.g., no embeddings)
|
|
475
|
+
if (!result.results || result.results.length === 0) {
|
|
476
|
+
getLogger().info('Semantic search empty, falling back to basic search', { projectId: args.projectId });
|
|
477
|
+
return callZephlyAPI('mcpSearchTasks', args);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Transform semantic search response to match expected format
|
|
481
|
+
return {
|
|
482
|
+
success: true,
|
|
483
|
+
tasks: result.results,
|
|
484
|
+
count: result.totalCount || result.results.length,
|
|
485
|
+
searchType: 'semantic',
|
|
486
|
+
query: args.searchText,
|
|
487
|
+
};
|
|
488
|
+
} catch (error) {
|
|
489
|
+
// Fall back to basic search if semantic search fails
|
|
490
|
+
getLogger().warn('Semantic search failed, falling back to basic search', { error: error.message });
|
|
491
|
+
return callZephlyAPI('mcpSearchTasks', args);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Use basic filter-based search when no searchText or projectId
|
|
496
|
+
return callZephlyAPI('mcpSearchTasks', args);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function getTask(args) {
|
|
500
|
+
const result = await callZephlyAPI('mcpGetTask', args);
|
|
501
|
+
const webUrl = await buildTaskUrl(result?.task?.taskNumber);
|
|
502
|
+
if (webUrl && result?.task) result.task.webUrl = webUrl;
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Link a commit to a task, deriving its changed files when the caller did not
|
|
507
|
+
// supply them.
|
|
508
|
+
//
|
|
509
|
+
// `files` has always been optional and callers routinely omit it, which costs
|
|
510
|
+
// more than it looks: without paths the task is invisible to auto-linking
|
|
511
|
+
// forever, since the engine has nothing to resolve against the component
|
|
512
|
+
// inventory. Measured on saltpig, 359 of 1224 tasks carrying a commit had no
|
|
513
|
+
// linked files at all.
|
|
514
|
+
//
|
|
515
|
+
// The fix is derivation rather than discipline. The commit SHA is already
|
|
516
|
+
// required, and the MCP server runs in the working tree where the commit was
|
|
517
|
+
// made, so the file list is a fact one command away — the agent still just
|
|
518
|
+
// passes a SHA. Best-effort throughout: a commit link must never fail because
|
|
519
|
+
// the files could not be read.
|
|
520
|
+
async function linkCommitToTask(args) {
|
|
521
|
+
if (Array.isArray(args.files) && args.files.length > 0) {
|
|
522
|
+
return callZephlyAPI('mcpLinkCommitToTask', args);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let files = [];
|
|
526
|
+
try {
|
|
527
|
+
const repoRoot = getRepositoryRoot(args.workingDirectory || process.cwd());
|
|
528
|
+
if (repoRoot) {
|
|
529
|
+
files = getCommitFiles(repoRoot, args.sha);
|
|
530
|
+
}
|
|
531
|
+
} catch (error) {
|
|
532
|
+
getLogger().warn('Could not derive commit files', { sha: args.sha, error: error.message });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (files.length === 0) {
|
|
536
|
+
return callZephlyAPI('mcpLinkCommitToTask', args);
|
|
537
|
+
}
|
|
538
|
+
getLogger().info('Derived commit files for link_commit', { sha: args.sha, count: files.length });
|
|
539
|
+
return callZephlyAPI('mcpLinkCommitToTask', { ...args, files });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function unlinkCommitFromTask(args) {
|
|
543
|
+
return callZephlyAPI('mcpUnlinkCommitFromTask', args);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function getTaskCommits(args) {
|
|
547
|
+
return callZephlyAPI('mcpGetTaskCommits', args);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Generate (and persist) the task's grounded "how it works" living description
|
|
551
|
+
// via the model. AI-quota gated server-side (mirrors projects.generateProjectHowItWorks).
|
|
552
|
+
async function generateTaskHowItWorks({ taskId }) {
|
|
553
|
+
return callZephlyAPI('mcpGenerateTaskHowItWorks', { taskId });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Apply (persist) a LOCAL-agent-authored "how it works" for a task (BYO-AI,
|
|
557
|
+
// E-190). The agent writes { markdown, sources }; the server validates the cited
|
|
558
|
+
// sources against the real grounded context before saving. No server model call.
|
|
559
|
+
async function applyTaskHowItWorks({ taskId, markdown, sources }) {
|
|
560
|
+
return callZephlyAPI('mcpApplyTaskHowItWorks', { taskId, markdown, sources });
|
|
561
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing Handlers
|
|
3
|
+
* Handler functions for testing-related MCP tools
|
|
4
|
+
*
|
|
5
|
+
* Consolidated:
|
|
6
|
+
* - manageTestCase dispatches create/update/delete/record_run
|
|
7
|
+
* - manageTestSuite dispatches create/update/delete/add_cases/remove_cases
|
|
8
|
+
* - listTestCases unifies get_test_case + list_test_cases
|
|
9
|
+
* - listTestSuites unifies get_test_suite + list_test_suites
|
|
10
|
+
* - getTestingSummary unchanged
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { callZephlyAPI } from '../lib/http-client.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Dispatch manage_test_case actions to the appropriate handler
|
|
17
|
+
*/
|
|
18
|
+
export async function manageTestCase(args) {
|
|
19
|
+
const { action, ...params } = args;
|
|
20
|
+
switch (action) {
|
|
21
|
+
case 'create': return createTestCase(params);
|
|
22
|
+
case 'update': return updateTestCase(params);
|
|
23
|
+
case 'delete': return deleteTestCase(params);
|
|
24
|
+
case 'record_run': return recordTestRun(params);
|
|
25
|
+
default: throw new Error(`Unknown action: ${action}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dispatch manage_test_suite actions to the appropriate handler
|
|
31
|
+
*/
|
|
32
|
+
export async function manageTestSuite(args) {
|
|
33
|
+
const { action, ...params } = args;
|
|
34
|
+
switch (action) {
|
|
35
|
+
case 'create': return createTestSuite(params);
|
|
36
|
+
case 'update': return updateTestSuite(params);
|
|
37
|
+
case 'delete': return deleteTestSuite(params);
|
|
38
|
+
case 'add_cases': return addCasesToSuite(params);
|
|
39
|
+
case 'remove_cases': return removeCasesFromSuite(params);
|
|
40
|
+
default: throw new Error(`Unknown action: ${action}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Unified get/list handler for test cases
|
|
46
|
+
*/
|
|
47
|
+
export async function listTestCases(args) {
|
|
48
|
+
// Single test case lookup
|
|
49
|
+
if (args.testCaseId) {
|
|
50
|
+
return callZephlyAPI('mcpGetTestCase', {
|
|
51
|
+
projectId: args.projectId,
|
|
52
|
+
caseId: args.testCaseId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// List with filters
|
|
57
|
+
return callZephlyAPI('mcpListTestCases', args);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Unified get/list handler for test suites
|
|
62
|
+
*/
|
|
63
|
+
export async function listTestSuites(args) {
|
|
64
|
+
// Single test suite lookup
|
|
65
|
+
if (args.testSuiteId) {
|
|
66
|
+
return callZephlyAPI('mcpGetTestSuite', {
|
|
67
|
+
projectId: args.projectId,
|
|
68
|
+
suiteId: args.testSuiteId,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// List with filters
|
|
73
|
+
return callZephlyAPI('mcpListTestSuites', args);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function getTestingSummary(args) {
|
|
77
|
+
return callZephlyAPI('mcpGetTestingSummary', args);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Private helpers ---
|
|
81
|
+
|
|
82
|
+
async function createTestCase(args) {
|
|
83
|
+
return callZephlyAPI('mcpCreateTestCase', args);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function updateTestCase(args) {
|
|
87
|
+
return callZephlyAPI('mcpUpdateTestCase', args);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function deleteTestCase(args) {
|
|
91
|
+
return callZephlyAPI('mcpDeleteTestCase', args);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function recordTestRun(args) {
|
|
95
|
+
return callZephlyAPI('mcpRecordTestRun', args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function createTestSuite(args) {
|
|
99
|
+
return callZephlyAPI('mcpCreateTestSuite', args);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function updateTestSuite(args) {
|
|
103
|
+
return callZephlyAPI('mcpUpdateTestSuite', args);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function deleteTestSuite(args) {
|
|
107
|
+
return callZephlyAPI('mcpDeleteTestSuite', args);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function addCasesToSuite(args) {
|
|
111
|
+
return callZephlyAPI('mcpAddCasesToSuite', args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function removeCasesFromSuite(args) {
|
|
115
|
+
return callZephlyAPI('mcpRemoveCasesFromSuite', args);
|
|
116
|
+
}
|