@harness-engineering/mcp-server 0.5.3 → 0.6.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.
@@ -0,0 +1,390 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { resultToMcpResponse } from '../utils/result-adapter.js';
4
+ import { sanitizePath } from '../utils/sanitize-path.js';
5
+ export const manageRoadmapDefinition = {
6
+ name: 'manage_roadmap',
7
+ description: 'Manage the project roadmap: show, add, update, remove, sync features, or query by filter. Reads and writes docs/roadmap.md.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ path: { type: 'string', description: 'Path to project root' },
12
+ action: {
13
+ type: 'string',
14
+ enum: ['show', 'add', 'update', 'remove', 'query', 'sync'],
15
+ description: 'Action to perform',
16
+ },
17
+ feature: { type: 'string', description: 'Feature name (required for add, update, remove)' },
18
+ milestone: {
19
+ type: 'string',
20
+ description: 'Milestone name (required for add; optional filter for show)',
21
+ },
22
+ status: {
23
+ type: 'string',
24
+ enum: ['backlog', 'planned', 'in-progress', 'done', 'blocked'],
25
+ description: 'Feature status (required for add; optional for update; optional filter for show)',
26
+ },
27
+ summary: {
28
+ type: 'string',
29
+ description: 'Feature summary (required for add; optional for update)',
30
+ },
31
+ spec: { type: 'string', description: 'Spec file path (optional for add/update)' },
32
+ plans: {
33
+ type: 'array',
34
+ items: { type: 'string' },
35
+ description: 'Plan file paths (optional for add/update)',
36
+ },
37
+ blocked_by: {
38
+ type: 'array',
39
+ items: { type: 'string' },
40
+ description: 'Blocking feature names (optional for add/update)',
41
+ },
42
+ filter: {
43
+ type: 'string',
44
+ description: 'Query filter: "blocked", "in-progress", "done", "planned", "backlog", or "milestone:<name>" (required for query)',
45
+ },
46
+ apply: {
47
+ type: 'boolean',
48
+ description: 'For sync action: apply proposed changes (default: false, preview only)',
49
+ },
50
+ force_sync: {
51
+ type: 'boolean',
52
+ description: 'For sync action: override human-always-wins rule',
53
+ },
54
+ },
55
+ required: ['path', 'action'],
56
+ },
57
+ };
58
+ function roadmapPath(projectRoot) {
59
+ return path.join(projectRoot, 'docs', 'roadmap.md');
60
+ }
61
+ function readRoadmapFile(projectRoot) {
62
+ const filePath = roadmapPath(projectRoot);
63
+ try {
64
+ return fs.readFileSync(filePath, 'utf-8');
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function writeRoadmapFile(projectRoot, content) {
71
+ const filePath = roadmapPath(projectRoot);
72
+ const dir = path.dirname(filePath);
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ fs.writeFileSync(filePath, content, 'utf-8');
75
+ }
76
+ export async function handleManageRoadmap(input) {
77
+ try {
78
+ const { parseRoadmap, serializeRoadmap, syncRoadmap } = await import('@harness-engineering/core');
79
+ const { Ok } = await import('@harness-engineering/types');
80
+ const projectPath = sanitizePath(input.path);
81
+ switch (input.action) {
82
+ case 'show': {
83
+ const raw = readRoadmapFile(projectPath);
84
+ if (raw === null) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
90
+ },
91
+ ],
92
+ isError: true,
93
+ };
94
+ }
95
+ const result = parseRoadmap(raw);
96
+ if (!result.ok)
97
+ return resultToMcpResponse(result);
98
+ let roadmap = result.value;
99
+ // Apply milestone filter
100
+ if (input.milestone) {
101
+ const milestoneFilter = input.milestone;
102
+ roadmap = {
103
+ ...roadmap,
104
+ milestones: roadmap.milestones.filter((m) => m.name.toLowerCase() === milestoneFilter.toLowerCase()),
105
+ };
106
+ }
107
+ // Apply status filter
108
+ if (input.status) {
109
+ const statusFilter = input.status;
110
+ roadmap = {
111
+ ...roadmap,
112
+ milestones: roadmap.milestones
113
+ .map((m) => ({
114
+ ...m,
115
+ features: m.features.filter((f) => f.status === statusFilter),
116
+ }))
117
+ .filter((m) => m.features.length > 0),
118
+ };
119
+ }
120
+ return resultToMcpResponse(Ok(roadmap));
121
+ }
122
+ case 'add': {
123
+ if (!input.feature) {
124
+ return {
125
+ content: [{ type: 'text', text: 'Error: feature is required for add action' }],
126
+ isError: true,
127
+ };
128
+ }
129
+ if (!input.milestone) {
130
+ return {
131
+ content: [
132
+ { type: 'text', text: 'Error: milestone is required for add action' },
133
+ ],
134
+ isError: true,
135
+ };
136
+ }
137
+ if (!input.status) {
138
+ return {
139
+ content: [{ type: 'text', text: 'Error: status is required for add action' }],
140
+ isError: true,
141
+ };
142
+ }
143
+ if (!input.summary) {
144
+ return {
145
+ content: [{ type: 'text', text: 'Error: summary is required for add action' }],
146
+ isError: true,
147
+ };
148
+ }
149
+ const raw = readRoadmapFile(projectPath);
150
+ if (raw === null) {
151
+ return {
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
156
+ },
157
+ ],
158
+ isError: true,
159
+ };
160
+ }
161
+ const result = parseRoadmap(raw);
162
+ if (!result.ok)
163
+ return resultToMcpResponse(result);
164
+ const roadmap = result.value;
165
+ const milestone = roadmap.milestones.find((m) => m.name.toLowerCase() === input.milestone.toLowerCase());
166
+ if (!milestone) {
167
+ return {
168
+ content: [
169
+ { type: 'text', text: `Error: milestone "${input.milestone}" not found` },
170
+ ],
171
+ isError: true,
172
+ };
173
+ }
174
+ milestone.features.push({
175
+ name: input.feature,
176
+ status: input.status,
177
+ spec: input.spec ?? null,
178
+ plans: input.plans ?? [],
179
+ blockedBy: input.blocked_by ?? [],
180
+ summary: input.summary,
181
+ });
182
+ // Update last_manual_edit timestamp
183
+ roadmap.frontmatter.lastManualEdit = new Date().toISOString();
184
+ writeRoadmapFile(projectPath, serializeRoadmap(roadmap));
185
+ return resultToMcpResponse(Ok(roadmap));
186
+ }
187
+ case 'update': {
188
+ if (!input.feature) {
189
+ return {
190
+ content: [
191
+ { type: 'text', text: 'Error: feature is required for update action' },
192
+ ],
193
+ isError: true,
194
+ };
195
+ }
196
+ const raw = readRoadmapFile(projectPath);
197
+ if (raw === null) {
198
+ return {
199
+ content: [
200
+ {
201
+ type: 'text',
202
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
203
+ },
204
+ ],
205
+ isError: true,
206
+ };
207
+ }
208
+ const result = parseRoadmap(raw);
209
+ if (!result.ok)
210
+ return resultToMcpResponse(result);
211
+ const roadmap = result.value;
212
+ let found = false;
213
+ for (const m of roadmap.milestones) {
214
+ const feature = m.features.find((f) => f.name.toLowerCase() === input.feature.toLowerCase());
215
+ if (feature) {
216
+ if (input.status)
217
+ feature.status = input.status;
218
+ if (input.summary !== undefined)
219
+ feature.summary = input.summary;
220
+ if (input.spec !== undefined)
221
+ feature.spec = input.spec || null;
222
+ if (input.plans !== undefined)
223
+ feature.plans = input.plans;
224
+ if (input.blocked_by !== undefined)
225
+ feature.blockedBy = input.blocked_by;
226
+ found = true;
227
+ break;
228
+ }
229
+ }
230
+ if (!found) {
231
+ return {
232
+ content: [
233
+ { type: 'text', text: `Error: feature "${input.feature}" not found` },
234
+ ],
235
+ isError: true,
236
+ };
237
+ }
238
+ roadmap.frontmatter.lastManualEdit = new Date().toISOString();
239
+ writeRoadmapFile(projectPath, serializeRoadmap(roadmap));
240
+ return resultToMcpResponse(Ok(roadmap));
241
+ }
242
+ case 'remove': {
243
+ if (!input.feature) {
244
+ return {
245
+ content: [
246
+ { type: 'text', text: 'Error: feature is required for remove action' },
247
+ ],
248
+ isError: true,
249
+ };
250
+ }
251
+ const raw = readRoadmapFile(projectPath);
252
+ if (raw === null) {
253
+ return {
254
+ content: [
255
+ {
256
+ type: 'text',
257
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
258
+ },
259
+ ],
260
+ isError: true,
261
+ };
262
+ }
263
+ const result = parseRoadmap(raw);
264
+ if (!result.ok)
265
+ return resultToMcpResponse(result);
266
+ const roadmap = result.value;
267
+ let found = false;
268
+ for (const m of roadmap.milestones) {
269
+ const idx = m.features.findIndex((f) => f.name.toLowerCase() === input.feature.toLowerCase());
270
+ if (idx !== -1) {
271
+ m.features.splice(idx, 1);
272
+ found = true;
273
+ break;
274
+ }
275
+ }
276
+ if (!found) {
277
+ return {
278
+ content: [
279
+ { type: 'text', text: `Error: feature "${input.feature}" not found` },
280
+ ],
281
+ isError: true,
282
+ };
283
+ }
284
+ roadmap.frontmatter.lastManualEdit = new Date().toISOString();
285
+ writeRoadmapFile(projectPath, serializeRoadmap(roadmap));
286
+ return resultToMcpResponse(Ok(roadmap));
287
+ }
288
+ case 'query': {
289
+ if (!input.filter) {
290
+ return {
291
+ content: [
292
+ { type: 'text', text: 'Error: filter is required for query action' },
293
+ ],
294
+ isError: true,
295
+ };
296
+ }
297
+ const raw = readRoadmapFile(projectPath);
298
+ if (raw === null) {
299
+ return {
300
+ content: [
301
+ {
302
+ type: 'text',
303
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
304
+ },
305
+ ],
306
+ isError: true,
307
+ };
308
+ }
309
+ const result = parseRoadmap(raw);
310
+ if (!result.ok)
311
+ return resultToMcpResponse(result);
312
+ const roadmap = result.value;
313
+ const allFeatures = roadmap.milestones.flatMap((m) => m.features.map((f) => ({ ...f, milestone: m.name })));
314
+ const filter = input.filter.toLowerCase();
315
+ let filtered;
316
+ if (filter.startsWith('milestone:')) {
317
+ const milestoneName = filter.slice('milestone:'.length).trim();
318
+ filtered = allFeatures.filter((f) => f.milestone.toLowerCase().includes(milestoneName));
319
+ }
320
+ else {
321
+ // Treat filter as a status value
322
+ filtered = allFeatures.filter((f) => f.status === filter);
323
+ }
324
+ return resultToMcpResponse(Ok(filtered));
325
+ }
326
+ case 'sync': {
327
+ const raw = readRoadmapFile(projectPath);
328
+ if (raw === null) {
329
+ return {
330
+ content: [
331
+ {
332
+ type: 'text',
333
+ text: 'Error: docs/roadmap.md not found. Create a roadmap first.',
334
+ },
335
+ ],
336
+ isError: true,
337
+ };
338
+ }
339
+ const result = parseRoadmap(raw);
340
+ if (!result.ok)
341
+ return resultToMcpResponse(result);
342
+ const roadmap = result.value;
343
+ const syncResult = syncRoadmap({
344
+ projectPath,
345
+ roadmap,
346
+ forceSync: input.force_sync ?? false,
347
+ });
348
+ if (!syncResult.ok)
349
+ return resultToMcpResponse(syncResult);
350
+ const changes = syncResult.value;
351
+ if (changes.length === 0) {
352
+ return resultToMcpResponse(Ok({ changes: [], message: 'Roadmap is up to date.' }));
353
+ }
354
+ if (input.apply) {
355
+ // Apply changes to roadmap
356
+ for (const change of changes) {
357
+ for (const m of roadmap.milestones) {
358
+ const feature = m.features.find((f) => f.name.toLowerCase() === change.feature.toLowerCase());
359
+ if (feature) {
360
+ feature.status = change.to;
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ roadmap.frontmatter.lastSynced = new Date().toISOString();
366
+ writeRoadmapFile(projectPath, serializeRoadmap(roadmap));
367
+ return resultToMcpResponse(Ok({ changes, applied: true, roadmap }));
368
+ }
369
+ return resultToMcpResponse(Ok({ changes, applied: false }));
370
+ }
371
+ default: {
372
+ return {
373
+ content: [{ type: 'text', text: `Error: unknown action` }],
374
+ isError: true,
375
+ };
376
+ }
377
+ }
378
+ }
379
+ catch (error) {
380
+ return {
381
+ content: [
382
+ {
383
+ type: 'text',
384
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
385
+ },
386
+ ],
387
+ isError: true,
388
+ };
389
+ }
390
+ }
@@ -1,4 +1,5 @@
1
1
  import * as path from 'path';
2
+ import { sanitizePath } from '../utils/sanitize-path.js';
2
3
  // ============ run_security_scan ============
3
4
  export const runSecurityScanDefinition = {
4
5
  name: 'run_security_scan',
@@ -23,7 +24,7 @@ export const runSecurityScanDefinition = {
23
24
  export async function handleRunSecurityScan(input) {
24
25
  try {
25
26
  const core = await import('@harness-engineering/core');
26
- const projectRoot = path.resolve(input.path);
27
+ const projectRoot = sanitizePath(input.path);
27
28
  // Load config from project
28
29
  let configData = {};
29
30
  try {
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import { Ok, Err } from '@harness-engineering/core';
4
4
  import { resultToMcpResponse } from '../utils/result-adapter.js';
5
5
  import { resolveSkillsDir } from '../utils/paths.js';
6
+ import { sanitizePath } from '../utils/sanitize-path.js';
6
7
  export const runSkillDefinition = {
7
8
  name: 'run_skill',
8
9
  description: 'Load and return the content of a skill (SKILL.md), optionally with project state context',
@@ -41,7 +42,7 @@ export async function handleRunSkill(input) {
41
42
  let content = fs.readFileSync(skillMdPath, 'utf-8');
42
43
  // Optionally inject project state context
43
44
  if (input.path) {
44
- const projectPath = path.resolve(input.path);
45
+ const projectPath = sanitizePath(input.path);
45
46
  const stateFile = path.join(projectPath, '.harness', 'state.json');
46
47
  if (fs.existsSync(stateFile)) {
47
48
  const stateContent = fs.readFileSync(stateFile, 'utf-8');
@@ -82,7 +83,7 @@ export async function handleCreateSkill(input) {
82
83
  name: input.name,
83
84
  description: input.description,
84
85
  cognitiveMode: input.cognitiveMode ?? 'constructive-architect',
85
- outputDir: path.resolve(input.path),
86
+ outputDir: sanitizePath(input.path),
86
87
  });
87
88
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
88
89
  }
@@ -1,6 +1,6 @@
1
- import * as path from 'path';
2
1
  import { Ok } from '@harness-engineering/core';
3
2
  import { resultToMcpResponse } from '../utils/result-adapter.js';
3
+ import { sanitizePath } from '../utils/sanitize-path.js';
4
4
  // ── manage_state ──────────────────────────────────────────────────────
5
5
  export const manageStateDefinition = {
6
6
  name: 'manage_state',
@@ -30,7 +30,7 @@ export const manageStateDefinition = {
30
30
  export async function handleManageState(input) {
31
31
  try {
32
32
  const { loadState, saveState, appendLearning, appendFailure, archiveFailures, runMechanicalGate, DEFAULT_STATE, } = await import('@harness-engineering/core');
33
- const projectPath = path.resolve(input.path);
33
+ const projectPath = sanitizePath(input.path);
34
34
  switch (input.action) {
35
35
  case 'show': {
36
36
  const result = await loadState(projectPath, input.stream);
@@ -136,7 +136,7 @@ export const manageHandoffDefinition = {
136
136
  export async function handleManageHandoff(input) {
137
137
  try {
138
138
  const { saveHandoff, loadHandoff } = await import('@harness-engineering/core');
139
- const projectPath = path.resolve(input.path);
139
+ const projectPath = sanitizePath(input.path);
140
140
  switch (input.action) {
141
141
  case 'save': {
142
142
  if (!input.handoff) {
@@ -189,7 +189,7 @@ export const listStreamsDefinition = {
189
189
  export async function handleListStreams(input) {
190
190
  try {
191
191
  const { listStreams, loadStreamIndex } = await import('@harness-engineering/core');
192
- const projectPath = path.resolve(input.path);
192
+ const projectPath = sanitizePath(input.path);
193
193
  const indexResult = await loadStreamIndex(projectPath);
194
194
  const streamsResult = await listStreams(projectPath);
195
195
  if (!streamsResult.ok)
@@ -19,4 +19,11 @@ export declare function handleValidateProject(input: {
19
19
  type: "text";
20
20
  text: string;
21
21
  }[];
22
+ isError: boolean;
23
+ } | {
24
+ content: {
25
+ type: "text";
26
+ text: string;
27
+ }[];
28
+ isError?: undefined;
22
29
  }>;
@@ -1,5 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { resolveProjectConfig } from '../utils/config-resolver.js';
3
+ import { sanitizePath } from '../utils/sanitize-path.js';
3
4
  export const validateToolDefinition = {
4
5
  name: 'validate_project',
5
6
  description: 'Run all validation checks on a harness engineering project',
@@ -12,7 +13,21 @@ export const validateToolDefinition = {
12
13
  },
13
14
  };
14
15
  export async function handleValidateProject(input) {
15
- const projectPath = path.resolve(input.path);
16
+ let projectPath;
17
+ try {
18
+ projectPath = sanitizePath(input.path);
19
+ }
20
+ catch (error) {
21
+ return {
22
+ content: [
23
+ {
24
+ type: 'text',
25
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
26
+ },
27
+ ],
28
+ isError: true,
29
+ };
30
+ }
16
31
  const errors = [];
17
32
  const checks = {
18
33
  config: 'fail',
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Validates and resolves an input path, rejecting filesystem root
3
+ * to prevent accidental broad filesystem access via MCP tools.
4
+ */
5
+ export declare function sanitizePath(inputPath: string): string;
@@ -0,0 +1,12 @@
1
+ import * as path from 'path';
2
+ /**
3
+ * Validates and resolves an input path, rejecting filesystem root
4
+ * to prevent accidental broad filesystem access via MCP tools.
5
+ */
6
+ export function sanitizePath(inputPath) {
7
+ const resolved = path.resolve(inputPath);
8
+ if (resolved === '/' || resolved === path.parse(resolved).root) {
9
+ throw new Error('Invalid project path: cannot use filesystem root');
10
+ }
11
+ return resolved;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-engineering/mcp-server",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for Harness Engineering toolkit",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -23,11 +23,11 @@
23
23
  "zod": "^3.22.0",
24
24
  "yaml": "^2.3.0",
25
25
  "handlebars": "^4.7.0",
26
- "@harness-engineering/core": "0.8.0",
26
+ "@harness-engineering/core": "0.9.0",
27
27
  "@harness-engineering/graph": "0.2.2",
28
- "@harness-engineering/types": "0.1.0",
29
- "@harness-engineering/cli": "1.7.0",
30
- "@harness-engineering/linter-gen": "0.1.1"
28
+ "@harness-engineering/cli": "1.8.0",
29
+ "@harness-engineering/linter-gen": "0.1.1",
30
+ "@harness-engineering/types": "0.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",