@harness-engineering/mcp-server 0.3.3 → 0.5.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,495 @@
1
+ import * as path from 'path';
2
+ import { loadGraphStore } from '../utils/graph-loader.js';
3
+ // ── Shared helper ────────────────────────────────────────────────────
4
+ function sanitizePath(inputPath) {
5
+ const resolved = path.resolve(inputPath);
6
+ if (resolved === '/' || resolved === path.parse(resolved).root) {
7
+ throw new Error('Invalid project path: cannot use filesystem root');
8
+ }
9
+ return resolved;
10
+ }
11
+ function graphNotFoundError() {
12
+ return {
13
+ content: [
14
+ {
15
+ type: 'text',
16
+ text: 'No graph found. Run `harness scan` or use `ingest_source` tool first.',
17
+ },
18
+ ],
19
+ isError: true,
20
+ };
21
+ }
22
+ // ── query_graph ──────────────────────────────────────────────────────
23
+ export const queryGraphDefinition = {
24
+ name: 'query_graph',
25
+ description: 'Query the project knowledge graph using ContextQL. Traverses from root nodes outward, filtering by node/edge types.',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ path: { type: 'string', description: 'Path to project root' },
30
+ rootNodeIds: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: 'Node IDs to start traversal from',
34
+ },
35
+ maxDepth: { type: 'number', description: 'Maximum traversal depth (default 3)' },
36
+ includeTypes: {
37
+ type: 'array',
38
+ items: { type: 'string' },
39
+ description: 'Only include nodes of these types',
40
+ },
41
+ excludeTypes: {
42
+ type: 'array',
43
+ items: { type: 'string' },
44
+ description: 'Exclude nodes of these types',
45
+ },
46
+ includeEdges: {
47
+ type: 'array',
48
+ items: { type: 'string' },
49
+ description: 'Only traverse edges of these types',
50
+ },
51
+ bidirectional: {
52
+ type: 'boolean',
53
+ description: 'Traverse edges in both directions (default false)',
54
+ },
55
+ pruneObservability: {
56
+ type: 'boolean',
57
+ description: 'Prune observability nodes like spans/metrics/logs (default true)',
58
+ },
59
+ },
60
+ required: ['path', 'rootNodeIds'],
61
+ },
62
+ };
63
+ export async function handleQueryGraph(input) {
64
+ try {
65
+ const projectPath = sanitizePath(input.path);
66
+ const store = await loadGraphStore(projectPath);
67
+ if (!store)
68
+ return graphNotFoundError();
69
+ const { ContextQL } = await import('@harness-engineering/graph');
70
+ const cql = new ContextQL(store);
71
+ const result = cql.execute({
72
+ rootNodeIds: input.rootNodeIds,
73
+ maxDepth: input.maxDepth,
74
+ includeTypes: input.includeTypes,
75
+ excludeTypes: input.excludeTypes,
76
+ includeEdges: input.includeEdges,
77
+ bidirectional: input.bidirectional,
78
+ pruneObservability: input.pruneObservability,
79
+ });
80
+ return {
81
+ content: [{ type: 'text', text: JSON.stringify(result) }],
82
+ };
83
+ }
84
+ catch (error) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
90
+ },
91
+ ],
92
+ isError: true,
93
+ };
94
+ }
95
+ }
96
+ // ── search_similar ───────────────────────────────────────────────────
97
+ export const searchSimilarDefinition = {
98
+ name: 'search_similar',
99
+ description: 'Search the knowledge graph for nodes similar to a query string using keyword and semantic fusion.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ path: { type: 'string', description: 'Path to project root' },
104
+ query: { type: 'string', description: 'Search query string' },
105
+ topK: { type: 'number', description: 'Maximum number of results to return (default 10)' },
106
+ },
107
+ required: ['path', 'query'],
108
+ },
109
+ };
110
+ export async function handleSearchSimilar(input) {
111
+ try {
112
+ const projectPath = sanitizePath(input.path);
113
+ const store = await loadGraphStore(projectPath);
114
+ if (!store)
115
+ return graphNotFoundError();
116
+ const { FusionLayer } = await import('@harness-engineering/graph');
117
+ const fusion = new FusionLayer(store);
118
+ const results = fusion.search(input.query, input.topK ?? 10);
119
+ return {
120
+ content: [{ type: 'text', text: JSON.stringify(results) }],
121
+ };
122
+ }
123
+ catch (error) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: 'text',
128
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
129
+ },
130
+ ],
131
+ isError: true,
132
+ };
133
+ }
134
+ }
135
+ // ── find_context_for ─────────────────────────────────────────────────
136
+ export const findContextForDefinition = {
137
+ name: 'find_context_for',
138
+ description: 'Find relevant context for a given intent by searching the graph and expanding around top results. Returns assembled context within a token budget.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ path: { type: 'string', description: 'Path to project root' },
143
+ intent: { type: 'string', description: 'Description of what context is needed for' },
144
+ tokenBudget: {
145
+ type: 'number',
146
+ description: 'Approximate token budget for results (default 4000)',
147
+ },
148
+ },
149
+ required: ['path', 'intent'],
150
+ },
151
+ };
152
+ export async function handleFindContextFor(input) {
153
+ try {
154
+ const projectPath = sanitizePath(input.path);
155
+ const store = await loadGraphStore(projectPath);
156
+ if (!store)
157
+ return graphNotFoundError();
158
+ const { FusionLayer, ContextQL } = await import('@harness-engineering/graph');
159
+ const fusion = new FusionLayer(store);
160
+ const cql = new ContextQL(store);
161
+ const tokenBudget = input.tokenBudget ?? 4000;
162
+ const charBudget = tokenBudget * 4;
163
+ // Find top relevant nodes
164
+ const searchResults = fusion.search(input.intent, 10);
165
+ if (searchResults.length === 0) {
166
+ return {
167
+ content: [
168
+ {
169
+ type: 'text',
170
+ text: JSON.stringify({ context: [], message: 'No relevant nodes found.' }),
171
+ },
172
+ ],
173
+ };
174
+ }
175
+ // Expand context around each top result
176
+ const contextBlocks = [];
177
+ let totalChars = 0;
178
+ for (const result of searchResults) {
179
+ if (totalChars >= charBudget)
180
+ break;
181
+ const expanded = cql.execute({
182
+ rootNodeIds: [result.nodeId],
183
+ maxDepth: 2,
184
+ });
185
+ const blockJson = JSON.stringify({
186
+ rootNode: result.nodeId,
187
+ score: result.score,
188
+ nodes: expanded.nodes,
189
+ edges: expanded.edges,
190
+ });
191
+ if (totalChars + blockJson.length > charBudget && contextBlocks.length > 0) {
192
+ break;
193
+ }
194
+ contextBlocks.push({
195
+ rootNode: result.nodeId,
196
+ score: result.score,
197
+ nodes: expanded.nodes,
198
+ edges: expanded.edges,
199
+ });
200
+ totalChars += blockJson.length;
201
+ }
202
+ return {
203
+ content: [
204
+ {
205
+ type: 'text',
206
+ text: JSON.stringify({
207
+ intent: input.intent,
208
+ tokenBudget,
209
+ blocksReturned: contextBlocks.length,
210
+ context: contextBlocks,
211
+ }),
212
+ },
213
+ ],
214
+ };
215
+ }
216
+ catch (error) {
217
+ return {
218
+ content: [
219
+ {
220
+ type: 'text',
221
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
222
+ },
223
+ ],
224
+ isError: true,
225
+ };
226
+ }
227
+ }
228
+ // ── get_relationships ────────────────────────────────────────────────
229
+ export const getRelationshipsDefinition = {
230
+ name: 'get_relationships',
231
+ description: 'Get relationships for a specific node in the knowledge graph, with configurable direction and depth.',
232
+ inputSchema: {
233
+ type: 'object',
234
+ properties: {
235
+ path: { type: 'string', description: 'Path to project root' },
236
+ nodeId: { type: 'string', description: 'ID of the node to get relationships for' },
237
+ direction: {
238
+ type: 'string',
239
+ enum: ['outbound', 'inbound', 'both'],
240
+ description: 'Direction of relationships to include (default both)',
241
+ },
242
+ depth: { type: 'number', description: 'Traversal depth (default 1)' },
243
+ },
244
+ required: ['path', 'nodeId'],
245
+ },
246
+ };
247
+ export async function handleGetRelationships(input) {
248
+ try {
249
+ const projectPath = sanitizePath(input.path);
250
+ const store = await loadGraphStore(projectPath);
251
+ if (!store)
252
+ return graphNotFoundError();
253
+ const { ContextQL } = await import('@harness-engineering/graph');
254
+ const cql = new ContextQL(store);
255
+ const direction = input.direction ?? 'both';
256
+ const bidirectional = direction === 'both' || direction === 'inbound';
257
+ const result = cql.execute({
258
+ rootNodeIds: [input.nodeId],
259
+ maxDepth: input.depth ?? 1,
260
+ bidirectional,
261
+ });
262
+ // Post-filter for inbound-only: remove outbound edges from the root node
263
+ let filteredNodes = result.nodes;
264
+ let filteredEdges = result.edges;
265
+ if (direction === 'inbound') {
266
+ filteredEdges = result.edges.filter((e) => e.from !== input.nodeId);
267
+ const reachableNodeIds = new Set(filteredEdges.map((e) => e.from));
268
+ reachableNodeIds.add(input.nodeId);
269
+ filteredNodes = result.nodes.filter((n) => reachableNodeIds.has(n.id));
270
+ }
271
+ return {
272
+ content: [
273
+ {
274
+ type: 'text',
275
+ text: JSON.stringify({
276
+ nodeId: input.nodeId,
277
+ direction,
278
+ depth: input.depth ?? 1,
279
+ nodes: filteredNodes,
280
+ edges: filteredEdges,
281
+ stats: result.stats,
282
+ }),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+ catch (error) {
288
+ return {
289
+ content: [
290
+ {
291
+ type: 'text',
292
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
293
+ },
294
+ ],
295
+ isError: true,
296
+ };
297
+ }
298
+ }
299
+ // ── get_impact ───────────────────────────────────────────────────────
300
+ export const getImpactDefinition = {
301
+ name: 'get_impact',
302
+ description: 'Analyze the impact of changing a node or file. Returns affected tests, docs, code, and other nodes grouped by type.',
303
+ inputSchema: {
304
+ type: 'object',
305
+ properties: {
306
+ path: { type: 'string', description: 'Path to project root' },
307
+ nodeId: { type: 'string', description: 'ID of the node to analyze impact for' },
308
+ filePath: {
309
+ type: 'string',
310
+ description: 'File path (relative to project root) to analyze impact for',
311
+ },
312
+ },
313
+ required: ['path'],
314
+ },
315
+ };
316
+ export async function handleGetImpact(input) {
317
+ try {
318
+ if (!input.nodeId && !input.filePath) {
319
+ return {
320
+ content: [
321
+ {
322
+ type: 'text',
323
+ text: 'Error: either nodeId or filePath is required',
324
+ },
325
+ ],
326
+ isError: true,
327
+ };
328
+ }
329
+ const projectPath = sanitizePath(input.path);
330
+ const store = await loadGraphStore(projectPath);
331
+ if (!store)
332
+ return graphNotFoundError();
333
+ const { ContextQL } = await import('@harness-engineering/graph');
334
+ let targetNodeId = input.nodeId;
335
+ // If filePath provided, resolve to nodeId
336
+ if (!targetNodeId && input.filePath) {
337
+ const fileNodes = store.findNodes({ type: 'file' });
338
+ const match = fileNodes.find((n) => n.path === input.filePath || n.id === `file:${input.filePath}`);
339
+ if (!match) {
340
+ return {
341
+ content: [
342
+ {
343
+ type: 'text',
344
+ text: `Error: no file node found matching path "${input.filePath}"`,
345
+ },
346
+ ],
347
+ isError: true,
348
+ };
349
+ }
350
+ targetNodeId = match.id;
351
+ }
352
+ const cql = new ContextQL(store);
353
+ const result = cql.execute({
354
+ rootNodeIds: [targetNodeId],
355
+ bidirectional: true,
356
+ maxDepth: 3,
357
+ });
358
+ // Group result nodes by type
359
+ const groups = {
360
+ tests: [],
361
+ docs: [],
362
+ code: [],
363
+ other: [],
364
+ };
365
+ const testTypes = new Set(['test_result']);
366
+ const docTypes = new Set(['adr', 'decision', 'document', 'learning']);
367
+ const codeTypes = new Set([
368
+ 'file',
369
+ 'module',
370
+ 'class',
371
+ 'interface',
372
+ 'function',
373
+ 'method',
374
+ 'variable',
375
+ ]);
376
+ for (const node of result.nodes) {
377
+ // Skip the target node itself
378
+ if (node.id === targetNodeId)
379
+ continue;
380
+ if (testTypes.has(node.type)) {
381
+ groups['tests'].push(node);
382
+ }
383
+ else if (docTypes.has(node.type)) {
384
+ groups['docs'].push(node);
385
+ }
386
+ else if (codeTypes.has(node.type)) {
387
+ groups['code'].push(node);
388
+ }
389
+ else {
390
+ groups['other'].push(node);
391
+ }
392
+ }
393
+ return {
394
+ content: [
395
+ {
396
+ type: 'text',
397
+ text: JSON.stringify({
398
+ targetNodeId,
399
+ impact: groups,
400
+ stats: result.stats,
401
+ edges: result.edges,
402
+ }),
403
+ },
404
+ ],
405
+ };
406
+ }
407
+ catch (error) {
408
+ return {
409
+ content: [
410
+ {
411
+ type: 'text',
412
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
413
+ },
414
+ ],
415
+ isError: true,
416
+ };
417
+ }
418
+ }
419
+ // ── ingest_source ────────────────────────────────────────────────────
420
+ export const ingestSourceDefinition = {
421
+ name: 'ingest_source',
422
+ description: 'Ingest sources into the project knowledge graph. Supports code analysis, knowledge documents, git history, or all at once.',
423
+ inputSchema: {
424
+ type: 'object',
425
+ properties: {
426
+ path: { type: 'string', description: 'Path to project root' },
427
+ source: {
428
+ type: 'string',
429
+ enum: ['code', 'knowledge', 'git', 'all'],
430
+ description: 'Type of source to ingest',
431
+ },
432
+ },
433
+ required: ['path', 'source'],
434
+ },
435
+ };
436
+ export async function handleIngestSource(input) {
437
+ try {
438
+ const projectPath = sanitizePath(input.path);
439
+ const graphDir = path.join(projectPath, '.harness', 'graph');
440
+ const { GraphStore, CodeIngestor, TopologicalLinker, KnowledgeIngestor, GitIngestor } = await import('@harness-engineering/graph');
441
+ const fs = await import('node:fs/promises');
442
+ // Ensure graph directory exists
443
+ await fs.mkdir(graphDir, { recursive: true });
444
+ // Try to load existing graph, or start fresh
445
+ const store = new GraphStore();
446
+ await store.load(graphDir);
447
+ const results = [];
448
+ if (input.source === 'code' || input.source === 'all') {
449
+ const codeIngestor = new CodeIngestor(store);
450
+ const codeResult = await codeIngestor.ingest(projectPath);
451
+ results.push(codeResult);
452
+ const linker = new TopologicalLinker(store);
453
+ linker.link();
454
+ }
455
+ if (input.source === 'knowledge' || input.source === 'all') {
456
+ const knowledgeIngestor = new KnowledgeIngestor(store);
457
+ const knowledgeResult = await knowledgeIngestor.ingestAll(projectPath);
458
+ results.push(knowledgeResult);
459
+ }
460
+ if (input.source === 'git' || input.source === 'all') {
461
+ const gitIngestor = new GitIngestor(store);
462
+ const gitResult = await gitIngestor.ingest(projectPath);
463
+ results.push(gitResult);
464
+ }
465
+ // Save the graph
466
+ await store.save(graphDir);
467
+ // Combine results
468
+ const combined = {
469
+ nodesAdded: results.reduce((s, r) => s + r.nodesAdded, 0),
470
+ nodesUpdated: results.reduce((s, r) => s + r.nodesUpdated, 0),
471
+ edgesAdded: results.reduce((s, r) => s + r.edgesAdded, 0),
472
+ edgesUpdated: results.reduce((s, r) => s + r.edgesUpdated, 0),
473
+ errors: results.flatMap((r) => r.errors),
474
+ durationMs: results.reduce((s, r) => s + r.durationMs, 0),
475
+ graphStats: {
476
+ totalNodes: store.nodeCount,
477
+ totalEdges: store.edgeCount,
478
+ },
479
+ };
480
+ return {
481
+ content: [{ type: 'text', text: JSON.stringify(combined) }],
482
+ };
483
+ }
484
+ catch (error) {
485
+ return {
486
+ content: [
487
+ {
488
+ type: 'text',
489
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
490
+ },
491
+ ],
492
+ isError: true,
493
+ };
494
+ }
495
+ }
@@ -44,6 +44,11 @@ export declare const runPersonaDefinition: {
44
44
  type: string;
45
45
  description: string;
46
46
  };
47
+ trigger: {
48
+ type: string;
49
+ enum: string[];
50
+ description: string;
51
+ };
47
52
  dryRun: {
48
53
  type: string;
49
54
  description: string;
@@ -55,5 +60,6 @@ export declare const runPersonaDefinition: {
55
60
  export declare function handleRunPersona(input: {
56
61
  persona: string;
57
62
  path?: string;
63
+ trigger?: string;
58
64
  dryRun?: boolean;
59
65
  }): Promise<import("../utils/result-adapter.js").McpToolResponse>;
@@ -55,34 +55,42 @@ export async function handleGeneratePersonaArtifacts(input) {
55
55
  }
56
56
  export const runPersonaDefinition = {
57
57
  name: 'run_persona',
58
- description: 'Execute all commands defined in a persona and return aggregated results',
58
+ description: 'Execute all steps defined in a persona and return aggregated results',
59
59
  inputSchema: {
60
60
  type: 'object',
61
61
  properties: {
62
62
  persona: { type: 'string', description: 'Persona name (e.g., architecture-enforcer)' },
63
63
  path: { type: 'string', description: 'Path to project root' },
64
+ trigger: {
65
+ type: 'string',
66
+ enum: [
67
+ 'always',
68
+ 'on_pr',
69
+ 'on_commit',
70
+ 'on_review',
71
+ 'scheduled',
72
+ 'manual',
73
+ 'on_plan_approved',
74
+ 'auto',
75
+ ],
76
+ description: 'Trigger context for step filtering (default: auto)',
77
+ },
64
78
  dryRun: { type: 'boolean', description: 'Preview without side effects' },
65
79
  },
66
80
  required: ['persona'],
67
81
  },
68
82
  };
69
83
  export async function handleRunPersona(input) {
70
- const { loadPersona, runPersona } = await import('@harness-engineering/cli');
84
+ const { loadPersona, runPersona, executeSkill } = await import('@harness-engineering/cli');
71
85
  const filePath = path.join(resolvePersonasDir(), `${input.persona}.yaml`);
72
86
  const personaResult = loadPersona(filePath);
73
87
  if (!personaResult.ok)
74
88
  return resultToMcpResponse(personaResult);
75
89
  const projectPath = input.path ? path.resolve(input.path) : process.cwd();
76
- const ALLOWED_COMMANDS = new Set([
77
- 'validate',
78
- 'check-deps',
79
- 'check-docs',
80
- 'cleanup',
81
- 'fix-drift',
82
- 'add',
83
- ]);
84
- const executor = async (command) => {
85
- if (!ALLOWED_COMMANDS.has(command)) {
90
+ const trigger = (input.trigger ?? 'auto');
91
+ const { ALLOWED_PERSONA_COMMANDS } = await import('@harness-engineering/cli');
92
+ const commandExecutor = async (command) => {
93
+ if (!ALLOWED_PERSONA_COMMANDS.has(command)) {
86
94
  return Err(new Error(`Unknown harness command: ${command}`));
87
95
  }
88
96
  try {
@@ -101,6 +109,11 @@ export async function handleRunPersona(input) {
101
109
  return Err(new Error(`${command} failed: ${error instanceof Error ? error.message : String(error)}`));
102
110
  }
103
111
  };
104
- const report = await runPersona(personaResult.value, executor);
112
+ const report = await runPersona(personaResult.value, {
113
+ trigger,
114
+ commandExecutor,
115
+ skillExecutor: executeSkill,
116
+ projectPath,
117
+ });
105
118
  return resultToMcpResponse(Ok(report));
106
119
  }
@@ -0,0 +1 @@
1
+ export declare function loadGraphStore(projectRoot: string): Promise<import("@harness-engineering/graph").GraphStore | null>;
@@ -0,0 +1,10 @@
1
+ import * as path from 'path';
2
+ export async function loadGraphStore(projectRoot) {
3
+ const { GraphStore } = await import('@harness-engineering/graph');
4
+ const graphDir = path.join(projectRoot, '.harness', 'graph');
5
+ const store = new GraphStore();
6
+ const loaded = await store.load(graphDir);
7
+ if (!loaded)
8
+ return null;
9
+ return store;
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-engineering/mcp-server",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for Harness Engineering toolkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,8 +23,9 @@
23
23
  "zod": "^3.22.0",
24
24
  "yaml": "^2.3.0",
25
25
  "handlebars": "^4.7.0",
26
- "@harness-engineering/core": "0.7.0",
27
- "@harness-engineering/cli": "1.3.0",
26
+ "@harness-engineering/core": "0.8.0",
27
+ "@harness-engineering/graph": "0.2.0",
28
+ "@harness-engineering/cli": "1.6.0",
28
29
  "@harness-engineering/linter-gen": "0.1.0",
29
30
  "@harness-engineering/types": "0.1.0"
30
31
  },