@bretwardjames/ghp-mcp 0.1.4 → 0.1.5
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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +67 -0
- package/README.md +3 -2
- package/dist/index.js +102 -17
- package/package.json +7 -4
- package/src/tool-registry.test.ts +233 -0
- package/src/tool-registry.ts +40 -0
- package/src/tools/add-issue.ts +5 -1
- package/src/tools/start.ts +5 -1
- package/src/tools/worktree.ts +17 -6
- package/vitest.config.ts +14 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @bretwardjames/ghp-mcp@0.1.
|
|
2
|
+
> @bretwardjames/ghp-mcp@0.1.5 build /home/bretwardjames/IdeaProjects/ghp/packages/mcp
|
|
3
3
|
> tsup src/index.ts --format esm --dts
|
|
4
4
|
|
|
5
5
|
CLI Building entry: src/index.ts
|
|
@@ -7,8 +7,8 @@ CLI Using tsconfig: tsconfig.json
|
|
|
7
7
|
CLI tsup v8.5.1
|
|
8
8
|
CLI Target: es2022
|
|
9
9
|
ESM Build start
|
|
10
|
-
ESM dist/index.js
|
|
11
|
-
ESM ⚡️ Build success in
|
|
10
|
+
ESM dist/index.js 55.56 KB
|
|
11
|
+
ESM ⚡️ Build success in 42ms
|
|
12
12
|
DTS Build start
|
|
13
|
-
DTS ⚡️ Build success in
|
|
13
|
+
DTS ⚡️ Build success in 2277ms
|
|
14
14
|
DTS dist/index.d.ts 20.00 B
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @bretwardjames/ghp-mcp
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- QA Checkpoint 2026-02-02
|
|
8
|
+
|
|
9
|
+
## @bretwardjames/ghp-core
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- Fix command injection vulnerabilities by using `spawn()` with array arguments instead of `exec()` with string interpolation
|
|
14
|
+
- Add `shell-utils` module with `shellEscape()`, `validateNumericInput()`, `validateSafeString()`, `validateUrl()`
|
|
15
|
+
|
|
16
|
+
### Error Handling
|
|
17
|
+
|
|
18
|
+
- Add `GitError` class that captures command, stderr, exitCode, and cwd for debugging
|
|
19
|
+
- Remove silent catch blocks from git-utils functions - errors now propagate properly
|
|
20
|
+
|
|
21
|
+
### New Features
|
|
22
|
+
|
|
23
|
+
- Add retry logic for transient GitHub API failures (`withRetry`, `isTransientError`, `calculateBackoffDelay`)
|
|
24
|
+
- Add configurable hook failure behavior (`OnFailureBehavior`: 'fail-fast' | 'continue')
|
|
25
|
+
- Support per-event hook settings via `eventDefaults` in event-hooks.json
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
- Fix repository field in GraphQL queries to return full `owner/repo` format
|
|
30
|
+
|
|
31
|
+
## @bretwardjames/ghp-cli
|
|
32
|
+
|
|
33
|
+
### New Features
|
|
34
|
+
|
|
35
|
+
- Add centralized exit utility with cleanup handler support (`registerCleanupHandler`, `exit`)
|
|
36
|
+
- Add validation module for enum flags, mutual exclusion, and numeric bounds
|
|
37
|
+
|
|
38
|
+
### Bug Fixes
|
|
39
|
+
|
|
40
|
+
- Fix `deepMergeObjects` to recursively merge nested config at all depths
|
|
41
|
+
- Fix type safety issues - replace `any` types with `SortableFieldValue` in sorting logic
|
|
42
|
+
- Fix `planCommand` parameter type from `any` to `Command | PlanOptions`
|
|
43
|
+
|
|
44
|
+
### Documentation
|
|
45
|
+
|
|
46
|
+
- Document all hook events (pre-pr, pr-creating) and template variables
|
|
47
|
+
- Update create-pr command to mention committing ragtime branch context
|
|
48
|
+
|
|
49
|
+
### Test Coverage
|
|
50
|
+
|
|
51
|
+
- Add 25 tests for CLI commands (start, add-issue)
|
|
52
|
+
- Add 21 tests for exit utility
|
|
53
|
+
- Add 34 tests for config operations
|
|
54
|
+
- Add 33 tests for validation module
|
|
55
|
+
|
|
56
|
+
## @bretwardjames/ghp-mcp
|
|
57
|
+
|
|
58
|
+
### Security
|
|
59
|
+
|
|
60
|
+
- Fix command injection in worktree operations
|
|
61
|
+
|
|
62
|
+
### Test Coverage
|
|
63
|
+
|
|
64
|
+
- Add 9 tests for tool registry
|
|
65
|
+
|
|
66
|
+
- Updated dependencies
|
|
67
|
+
- Updated dependencies [fbe1b3c]
|
|
68
|
+
- @bretwardjames/ghp-core@0.6.0
|
|
69
|
+
|
|
3
70
|
## 0.1.4
|
|
4
71
|
|
|
5
72
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -65,13 +65,14 @@ The server uses the same GitHub authentication as the CLI. Run `ghp auth` to aut
|
|
|
65
65
|
|
|
66
66
|
| Tool | Description |
|
|
67
67
|
|------|-------------|
|
|
68
|
-
| `
|
|
68
|
+
| `get_my_work` | View items assigned to you |
|
|
69
69
|
| `get_project_board` | View project board/items (with optional status/assignee filters) |
|
|
70
70
|
| `create_issue` | Create a new issue and add to project |
|
|
71
71
|
| `update_issue` | Update an issue's title and/or body |
|
|
72
72
|
| `move` | Move an issue to a different status |
|
|
73
73
|
| `done` | Mark an issue as done |
|
|
74
74
|
| `start` | Start working on an issue |
|
|
75
|
+
| `create_worktree` | Create a worktree for parallel development |
|
|
75
76
|
| `assign` | Assign users to an issue |
|
|
76
77
|
| `comment` | Add a comment to an issue |
|
|
77
78
|
| `set_field` | Set a field value on an issue |
|
|
@@ -80,7 +81,7 @@ The server uses the same GitHub authentication as the CLI. Run `ghp auth` to aut
|
|
|
80
81
|
|
|
81
82
|
```
|
|
82
83
|
AI: "Show me my current work items"
|
|
83
|
-
→ Uses the `
|
|
84
|
+
→ Uses the `get_my_work` tool
|
|
84
85
|
|
|
85
86
|
AI: "Create a bug report for the login timeout issue"
|
|
86
87
|
→ Uses `create_issue` with appropriate title/body
|
package/dist/index.js
CHANGED
|
@@ -120,7 +120,7 @@ function createTokenProvider() {
|
|
|
120
120
|
import { existsSync, readFileSync } from "fs";
|
|
121
121
|
import { homedir } from "os";
|
|
122
122
|
import { join } from "path";
|
|
123
|
-
import { execSync
|
|
123
|
+
import { execSync } from "child_process";
|
|
124
124
|
|
|
125
125
|
// src/tools/work.ts
|
|
126
126
|
var work_exports = {};
|
|
@@ -671,6 +671,11 @@ __export(start_exports, {
|
|
|
671
671
|
register: () => register5
|
|
672
672
|
});
|
|
673
673
|
import * as z5 from "zod";
|
|
674
|
+
import {
|
|
675
|
+
getCurrentBranch,
|
|
676
|
+
executeHooksForEvent,
|
|
677
|
+
hasHooksForEvent
|
|
678
|
+
} from "@bretwardjames/ghp-core";
|
|
674
679
|
var meta5 = {
|
|
675
680
|
name: "start_work",
|
|
676
681
|
category: "action"
|
|
@@ -771,16 +776,7 @@ WARNING: This issue is blocked by: ${openBlockers.map((b) => `#${b.number} (${b.
|
|
|
771
776
|
statusField.fieldId,
|
|
772
777
|
inProgressOption.id
|
|
773
778
|
);
|
|
774
|
-
if (success) {
|
|
775
|
-
return {
|
|
776
|
-
content: [
|
|
777
|
-
{
|
|
778
|
-
type: "text",
|
|
779
|
-
text: `Started work on issue #${issue} "${item.title}" - status set to "${inProgressOption.name}".${blockingWarning}`
|
|
780
|
-
}
|
|
781
|
-
]
|
|
782
|
-
};
|
|
783
|
-
} else {
|
|
779
|
+
if (!success) {
|
|
784
780
|
return {
|
|
785
781
|
content: [
|
|
786
782
|
{
|
|
@@ -791,6 +787,43 @@ WARNING: This issue is blocked by: ${openBlockers.map((b) => `#${b.number} (${b.
|
|
|
791
787
|
isError: true
|
|
792
788
|
};
|
|
793
789
|
}
|
|
790
|
+
let hookInfo = "";
|
|
791
|
+
if (hasHooksForEvent("issue-started")) {
|
|
792
|
+
const branch = await getCurrentBranch() || "";
|
|
793
|
+
const payload = {
|
|
794
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
795
|
+
issue: {
|
|
796
|
+
number: issue,
|
|
797
|
+
title: item.title,
|
|
798
|
+
body: "",
|
|
799
|
+
// Body not available from ProjectItem
|
|
800
|
+
url: `https://github.com/${repo.owner}/${repo.name}/issues/${issue}`
|
|
801
|
+
},
|
|
802
|
+
branch
|
|
803
|
+
};
|
|
804
|
+
const hooksConfig = loadHooksConfig();
|
|
805
|
+
const hookResults = await executeHooksForEvent("issue-started", payload, {
|
|
806
|
+
onFailure: hooksConfig.onFailure
|
|
807
|
+
});
|
|
808
|
+
const successCount = hookResults.filter((r) => r.success).length;
|
|
809
|
+
const failCount = hookResults.length - successCount;
|
|
810
|
+
if (hookResults.length > 0) {
|
|
811
|
+
hookInfo = `
|
|
812
|
+
|
|
813
|
+
Hooks: ${successCount} succeeded`;
|
|
814
|
+
if (failCount > 0) {
|
|
815
|
+
hookInfo += `, ${failCount} failed`;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
content: [
|
|
821
|
+
{
|
|
822
|
+
type: "text",
|
|
823
|
+
text: `Started work on issue #${issue} "${item.title}" - status set to "${inProgressOption.name}".${blockingWarning}${hookInfo}`
|
|
824
|
+
}
|
|
825
|
+
]
|
|
826
|
+
};
|
|
794
827
|
} catch (error) {
|
|
795
828
|
return {
|
|
796
829
|
content: [
|
|
@@ -813,6 +846,10 @@ __export(add_issue_exports, {
|
|
|
813
846
|
register: () => register6
|
|
814
847
|
});
|
|
815
848
|
import * as z6 from "zod";
|
|
849
|
+
import {
|
|
850
|
+
executeHooksForEvent as executeHooksForEvent2,
|
|
851
|
+
hasHooksForEvent as hasHooksForEvent2
|
|
852
|
+
} from "@bretwardjames/ghp-core";
|
|
816
853
|
var meta6 = {
|
|
817
854
|
name: "create_issue",
|
|
818
855
|
category: "action"
|
|
@@ -910,6 +947,31 @@ Warning: Status "${status}" not found. Valid options: ${validStatuses}`;
|
|
|
910
947
|
}
|
|
911
948
|
}
|
|
912
949
|
}
|
|
950
|
+
if (hasHooksForEvent2("issue-created")) {
|
|
951
|
+
const payload = {
|
|
952
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
953
|
+
issue: {
|
|
954
|
+
number: result.number,
|
|
955
|
+
title,
|
|
956
|
+
body: body || "",
|
|
957
|
+
url: issueUrl
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
const hooksConfig = loadHooksConfig();
|
|
961
|
+
const hookResults = await executeHooksForEvent2("issue-created", payload, {
|
|
962
|
+
onFailure: hooksConfig.onFailure
|
|
963
|
+
});
|
|
964
|
+
const successCount = hookResults.filter((r) => r.success).length;
|
|
965
|
+
const failCount = hookResults.length - successCount;
|
|
966
|
+
if (hookResults.length > 0) {
|
|
967
|
+
message += `
|
|
968
|
+
|
|
969
|
+
Hooks: ${successCount} succeeded`;
|
|
970
|
+
if (failCount > 0) {
|
|
971
|
+
message += `, ${failCount} failed`;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
913
975
|
return {
|
|
914
976
|
content: [
|
|
915
977
|
{
|
|
@@ -1348,7 +1410,8 @@ __export(worktree_exports, {
|
|
|
1348
1410
|
register: () => register11
|
|
1349
1411
|
});
|
|
1350
1412
|
import * as z11 from "zod";
|
|
1351
|
-
import {
|
|
1413
|
+
import { spawnSync } from "child_process";
|
|
1414
|
+
import { validateNumericInput } from "@bretwardjames/ghp-core";
|
|
1352
1415
|
var meta11 = {
|
|
1353
1416
|
name: "create_worktree",
|
|
1354
1417
|
category: "action"
|
|
@@ -1404,18 +1467,23 @@ function register11(server, context) {
|
|
|
1404
1467
|
};
|
|
1405
1468
|
}
|
|
1406
1469
|
try {
|
|
1407
|
-
|
|
1470
|
+
const safeIssue = validateNumericInput(issue, "issue");
|
|
1471
|
+
const args = ["start", String(safeIssue), "--parallel", "-fd", "--force"];
|
|
1408
1472
|
if (worktreePath) {
|
|
1409
|
-
|
|
1473
|
+
args.push("--worktree-path", worktreePath);
|
|
1410
1474
|
}
|
|
1411
1475
|
if (spawnSubagent) {
|
|
1412
|
-
|
|
1476
|
+
args.push("--spawn-subagent");
|
|
1413
1477
|
}
|
|
1414
|
-
const
|
|
1478
|
+
const result = spawnSync("ghp", args, {
|
|
1415
1479
|
encoding: "utf-8",
|
|
1416
1480
|
cwd: process.cwd(),
|
|
1417
1481
|
env: process.env
|
|
1418
1482
|
});
|
|
1483
|
+
if (result.status !== 0) {
|
|
1484
|
+
throw new Error(result.stderr || `ghp start failed with exit code ${result.status}`);
|
|
1485
|
+
}
|
|
1486
|
+
const output = result.stdout;
|
|
1419
1487
|
const directive = spawnSubagent ? parseSpawnDirective(output) : null;
|
|
1420
1488
|
const response = {
|
|
1421
1489
|
content: [
|
|
@@ -1476,7 +1544,7 @@ var DEFAULT_MCP_CONFIG = {
|
|
|
1476
1544
|
};
|
|
1477
1545
|
function getRepoRoot() {
|
|
1478
1546
|
try {
|
|
1479
|
-
return
|
|
1547
|
+
return execSync("git rev-parse --show-toplevel", {
|
|
1480
1548
|
encoding: "utf-8",
|
|
1481
1549
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1482
1550
|
}).trim();
|
|
@@ -1528,6 +1596,23 @@ function loadMcpConfig() {
|
|
|
1528
1596
|
}
|
|
1529
1597
|
return result;
|
|
1530
1598
|
}
|
|
1599
|
+
function loadHooksConfig() {
|
|
1600
|
+
const userConfigPath = join(homedir(), ".config", "ghp-cli", "config.json");
|
|
1601
|
+
const userConfig = loadConfigFile(userConfigPath);
|
|
1602
|
+
const repoRoot = getRepoRoot();
|
|
1603
|
+
const workspaceConfigPath = repoRoot ? join(repoRoot, ".ghp", "config.json") : null;
|
|
1604
|
+
const workspaceConfig = workspaceConfigPath ? loadConfigFile(workspaceConfigPath) : null;
|
|
1605
|
+
const result = { onFailure: "fail-fast" };
|
|
1606
|
+
const userHooks = userConfig?.hooks;
|
|
1607
|
+
if (userHooks?.onFailure === "fail-fast" || userHooks?.onFailure === "continue") {
|
|
1608
|
+
result.onFailure = userHooks.onFailure;
|
|
1609
|
+
}
|
|
1610
|
+
const workspaceHooks = workspaceConfig?.hooks;
|
|
1611
|
+
if (workspaceHooks?.onFailure === "fail-fast" || workspaceHooks?.onFailure === "continue") {
|
|
1612
|
+
result.onFailure = workspaceHooks.onFailure;
|
|
1613
|
+
}
|
|
1614
|
+
return result;
|
|
1615
|
+
}
|
|
1531
1616
|
function isToolEnabled(tool, config) {
|
|
1532
1617
|
const toolsConfig = config.tools || DEFAULT_MCP_CONFIG.tools;
|
|
1533
1618
|
const disabledTools = new Set(config.disabledTools || []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bretwardjames/ghp-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "MCP server for ghp (GitHub Projects)",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -29,12 +29,13 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
31
31
|
"zod": "^3.22.0",
|
|
32
|
-
"@bretwardjames/ghp-core": "0.
|
|
32
|
+
"@bretwardjames/ghp-core": "0.6.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^20.10.0",
|
|
36
36
|
"tsup": "^8.0.0",
|
|
37
|
-
"typescript": "^5.3.2"
|
|
37
|
+
"typescript": "^5.3.2",
|
|
38
|
+
"vitest": "^3.0.5"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=18"
|
|
@@ -42,6 +43,8 @@
|
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "tsup src/index.ts --format esm --dts",
|
|
44
45
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
45
|
-
"clean": "rm -rf dist"
|
|
46
|
+
"clean": "rm -rf dist",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the tool registry
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// Mock fs
|
|
8
|
+
vi.mock('fs', () => ({
|
|
9
|
+
existsSync: vi.fn(() => false),
|
|
10
|
+
readFileSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock os
|
|
14
|
+
vi.mock('os', () => ({
|
|
15
|
+
homedir: vi.fn(() => '/home/testuser'),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock child_process
|
|
19
|
+
vi.mock('child_process', () => ({
|
|
20
|
+
execSync: vi.fn(() => '/test/repo'),
|
|
21
|
+
exec: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock util for promisify
|
|
25
|
+
vi.mock('util', async () => {
|
|
26
|
+
const actual = await vi.importActual<typeof import('util')>('util');
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
promisify: vi.fn(() => vi.fn()),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Mock @bretwardjames/ghp-core
|
|
34
|
+
vi.mock('@bretwardjames/ghp-core', () => ({
|
|
35
|
+
getCurrentBranch: vi.fn(),
|
|
36
|
+
executeHooksForEvent: vi.fn(() => []),
|
|
37
|
+
hasHooksForEvent: vi.fn(() => false),
|
|
38
|
+
branchExists: vi.fn(),
|
|
39
|
+
createBranch: vi.fn(),
|
|
40
|
+
checkoutBranch: vi.fn(),
|
|
41
|
+
generateBranchName: vi.fn(),
|
|
42
|
+
createWorktree: vi.fn(),
|
|
43
|
+
removeWorktree: vi.fn(),
|
|
44
|
+
listWorktrees: vi.fn(),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Import after mocks
|
|
48
|
+
import { loadMcpConfig, getToolList, registerEnabledTools } from './tool-registry.js';
|
|
49
|
+
import { existsSync, readFileSync } from 'fs';
|
|
50
|
+
import { execSync } from 'child_process';
|
|
51
|
+
|
|
52
|
+
describe('tool-registry', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('loadMcpConfig', () => {
|
|
58
|
+
it('should return default config when no config files exist', () => {
|
|
59
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
60
|
+
|
|
61
|
+
const config = loadMcpConfig();
|
|
62
|
+
|
|
63
|
+
expect(config).toEqual({
|
|
64
|
+
tools: {
|
|
65
|
+
read: true,
|
|
66
|
+
action: true,
|
|
67
|
+
},
|
|
68
|
+
disabledTools: [],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should merge user config with defaults', () => {
|
|
73
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
74
|
+
return String(path).includes('.config/ghp-cli');
|
|
75
|
+
});
|
|
76
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
77
|
+
mcp: {
|
|
78
|
+
tools: { read: false },
|
|
79
|
+
disabledTools: ['create_issue'],
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const config = loadMcpConfig();
|
|
84
|
+
|
|
85
|
+
expect(config.tools?.read).toBe(false);
|
|
86
|
+
expect(config.tools?.action).toBe(true); // from defaults
|
|
87
|
+
expect(config.disabledTools).toContain('create_issue');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should prefer workspace config over user config', () => {
|
|
91
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
92
|
+
vi.mocked(readFileSync).mockImplementation((path) => {
|
|
93
|
+
if (String(path).includes('.ghp/config.json')) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
mcp: { tools: { action: false } },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return JSON.stringify({
|
|
99
|
+
mcp: { tools: { action: true } },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const config = loadMcpConfig();
|
|
104
|
+
|
|
105
|
+
expect(config.tools?.action).toBe(false); // workspace wins
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should handle JSON with comments', () => {
|
|
109
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
110
|
+
return String(path).includes('.config/ghp-cli');
|
|
111
|
+
});
|
|
112
|
+
vi.mocked(readFileSync).mockReturnValue(`{
|
|
113
|
+
// This is a comment
|
|
114
|
+
"mcp": {
|
|
115
|
+
/* Block comment */
|
|
116
|
+
"tools": { "read": false }
|
|
117
|
+
}
|
|
118
|
+
}`);
|
|
119
|
+
|
|
120
|
+
const config = loadMcpConfig();
|
|
121
|
+
|
|
122
|
+
expect(config.tools?.read).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should handle missing git repo gracefully', () => {
|
|
126
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
127
|
+
throw new Error('Not a git repo');
|
|
128
|
+
});
|
|
129
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
130
|
+
|
|
131
|
+
const config = loadMcpConfig();
|
|
132
|
+
|
|
133
|
+
// Should still return defaults
|
|
134
|
+
expect(config.tools?.read).toBe(true);
|
|
135
|
+
expect(config.tools?.action).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('getToolList', () => {
|
|
140
|
+
it('should return all registered tools with their categories', () => {
|
|
141
|
+
const tools = getToolList();
|
|
142
|
+
|
|
143
|
+
// Should have multiple tools
|
|
144
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
145
|
+
|
|
146
|
+
// Each tool should have name and category
|
|
147
|
+
for (const tool of tools) {
|
|
148
|
+
expect(tool).toHaveProperty('name');
|
|
149
|
+
expect(tool).toHaveProperty('category');
|
|
150
|
+
expect(['read', 'action']).toContain(tool.category);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check some expected tools exist
|
|
154
|
+
const toolNames = tools.map(t => t.name);
|
|
155
|
+
expect(toolNames).toContain('create_issue');
|
|
156
|
+
expect(toolNames).toContain('get_my_work'); // actual tool name
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('registerEnabledTools', () => {
|
|
161
|
+
it('should register tools based on config', () => {
|
|
162
|
+
const mockServer = {
|
|
163
|
+
registerTool: vi.fn(),
|
|
164
|
+
};
|
|
165
|
+
const mockContext = {
|
|
166
|
+
ensureAuthenticated: vi.fn(),
|
|
167
|
+
getRepo: vi.fn(),
|
|
168
|
+
api: {},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// All categories enabled
|
|
172
|
+
registerEnabledTools(mockServer as any, mockContext as any, {
|
|
173
|
+
tools: { read: true, action: true },
|
|
174
|
+
disabledTools: [],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Should have registered multiple tools
|
|
178
|
+
expect(mockServer.registerTool).toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should skip disabled categories', () => {
|
|
182
|
+
const mockServer = {
|
|
183
|
+
registerTool: vi.fn(),
|
|
184
|
+
};
|
|
185
|
+
const mockContext = {
|
|
186
|
+
ensureAuthenticated: vi.fn(),
|
|
187
|
+
getRepo: vi.fn(),
|
|
188
|
+
api: {},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Only read category enabled
|
|
192
|
+
registerEnabledTools(mockServer as any, mockContext as any, {
|
|
193
|
+
tools: { read: true, action: false },
|
|
194
|
+
disabledTools: [],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Get the names of registered tools
|
|
198
|
+
const registeredNames = mockServer.registerTool.mock.calls.map(
|
|
199
|
+
(call) => call[0]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Should only have read tools (list_work_items, view_plan)
|
|
203
|
+
// No action tools (create_issue, move_issue, etc.)
|
|
204
|
+
expect(registeredNames).not.toContain('create_issue');
|
|
205
|
+
expect(registeredNames).not.toContain('move_issue');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should skip specifically disabled tools', () => {
|
|
209
|
+
const mockServer = {
|
|
210
|
+
registerTool: vi.fn(),
|
|
211
|
+
};
|
|
212
|
+
const mockContext = {
|
|
213
|
+
ensureAuthenticated: vi.fn(),
|
|
214
|
+
getRepo: vi.fn(),
|
|
215
|
+
api: {},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// All categories enabled but specific tool disabled
|
|
219
|
+
registerEnabledTools(mockServer as any, mockContext as any, {
|
|
220
|
+
tools: { read: true, action: true },
|
|
221
|
+
disabledTools: ['create_issue'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const registeredNames = mockServer.registerTool.mock.calls.map(
|
|
225
|
+
(call) => call[0]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(registeredNames).not.toContain('create_issue');
|
|
229
|
+
// Other action tools should still be registered
|
|
230
|
+
expect(registeredNames).toContain('move_issue');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/tool-registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ServerContext } from './server.js';
|
|
3
3
|
import type { ToolCategory, McpConfig, McpToolsConfig } from './types.js';
|
|
4
|
+
import type { OnFailureBehavior } from '@bretwardjames/ghp-core';
|
|
4
5
|
import { existsSync, readFileSync } from 'fs';
|
|
5
6
|
import { homedir } from 'os';
|
|
6
7
|
import { join } from 'path';
|
|
@@ -142,6 +143,45 @@ export function loadMcpConfig(): McpConfig {
|
|
|
142
143
|
return result;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Hooks configuration for onFailure behavior
|
|
148
|
+
*/
|
|
149
|
+
export interface HooksConfig {
|
|
150
|
+
onFailure: OnFailureBehavior;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load hooks configuration from user and workspace config files.
|
|
155
|
+
* Workspace config takes precedence over user config.
|
|
156
|
+
*/
|
|
157
|
+
export function loadHooksConfig(): HooksConfig {
|
|
158
|
+
// User config: ~/.config/ghp-cli/config.json
|
|
159
|
+
const userConfigPath = join(homedir(), '.config', 'ghp-cli', 'config.json');
|
|
160
|
+
const userConfig = loadConfigFile(userConfigPath);
|
|
161
|
+
|
|
162
|
+
// Workspace config: <repo-root>/.ghp/config.json
|
|
163
|
+
const repoRoot = getRepoRoot();
|
|
164
|
+
const workspaceConfigPath = repoRoot ? join(repoRoot, '.ghp', 'config.json') : null;
|
|
165
|
+
const workspaceConfig = workspaceConfigPath ? loadConfigFile(workspaceConfigPath) : null;
|
|
166
|
+
|
|
167
|
+
// Default
|
|
168
|
+
const result: HooksConfig = { onFailure: 'fail-fast' };
|
|
169
|
+
|
|
170
|
+
// Apply user config
|
|
171
|
+
const userHooks = userConfig?.hooks as { onFailure?: string } | undefined;
|
|
172
|
+
if (userHooks?.onFailure === 'fail-fast' || userHooks?.onFailure === 'continue') {
|
|
173
|
+
result.onFailure = userHooks.onFailure;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Apply workspace config (takes precedence)
|
|
177
|
+
const workspaceHooks = workspaceConfig?.hooks as { onFailure?: string } | undefined;
|
|
178
|
+
if (workspaceHooks?.onFailure === 'fail-fast' || workspaceHooks?.onFailure === 'continue') {
|
|
179
|
+
result.onFailure = workspaceHooks.onFailure;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
145
185
|
/**
|
|
146
186
|
* Get list of all tool names and categories
|
|
147
187
|
*/
|
package/src/tools/add-issue.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import * as z from 'zod';
|
|
3
3
|
import type { ServerContext } from '../server.js';
|
|
4
4
|
import type { ToolMeta } from '../types.js';
|
|
5
|
+
import { loadHooksConfig } from '../tool-registry.js';
|
|
5
6
|
import {
|
|
6
7
|
executeHooksForEvent,
|
|
7
8
|
hasHooksForEvent,
|
|
@@ -132,7 +133,10 @@ export function register(server: McpServer, context: ServerContext): void {
|
|
|
132
133
|
},
|
|
133
134
|
};
|
|
134
135
|
|
|
135
|
-
const
|
|
136
|
+
const hooksConfig = loadHooksConfig();
|
|
137
|
+
const hookResults = await executeHooksForEvent('issue-created', payload, {
|
|
138
|
+
onFailure: hooksConfig.onFailure,
|
|
139
|
+
});
|
|
136
140
|
const successCount = hookResults.filter(r => r.success).length;
|
|
137
141
|
const failCount = hookResults.length - successCount;
|
|
138
142
|
|
package/src/tools/start.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import * as z from 'zod';
|
|
3
3
|
import type { ServerContext } from '../server.js';
|
|
4
4
|
import type { ToolMeta } from '../types.js';
|
|
5
|
+
import { loadHooksConfig } from '../tool-registry.js';
|
|
5
6
|
import {
|
|
6
7
|
getCurrentBranch,
|
|
7
8
|
executeHooksForEvent,
|
|
@@ -162,7 +163,10 @@ export function register(server: McpServer, context: ServerContext): void {
|
|
|
162
163
|
branch,
|
|
163
164
|
};
|
|
164
165
|
|
|
165
|
-
const
|
|
166
|
+
const hooksConfig = loadHooksConfig();
|
|
167
|
+
const hookResults = await executeHooksForEvent('issue-started', payload, {
|
|
168
|
+
onFailure: hooksConfig.onFailure,
|
|
169
|
+
});
|
|
166
170
|
const successCount = hookResults.filter(r => r.success).length;
|
|
167
171
|
const failCount = hookResults.length - successCount;
|
|
168
172
|
|
package/src/tools/worktree.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import * as z from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { validateNumericInput } from '@bretwardjames/ghp-core';
|
|
4
5
|
import type { ServerContext } from '../server.js';
|
|
5
6
|
import type { ToolMeta } from '../types.js';
|
|
6
7
|
|
|
@@ -102,22 +103,32 @@ export function register(server: McpServer, context: ServerContext): void {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
try {
|
|
105
|
-
//
|
|
106
|
-
|
|
106
|
+
// Validate issue number to prevent injection
|
|
107
|
+
const safeIssue = validateNumericInput(issue, 'issue');
|
|
108
|
+
|
|
109
|
+
// Build CLI args as array to prevent command injection
|
|
110
|
+
// Using spawnSync with array args avoids shell entirely
|
|
111
|
+
const args = ['start', String(safeIssue), '--parallel', '-fd', '--force'];
|
|
107
112
|
if (worktreePath) {
|
|
108
|
-
|
|
113
|
+
args.push('--worktree-path', worktreePath);
|
|
109
114
|
}
|
|
110
115
|
if (spawnSubagent) {
|
|
111
|
-
|
|
116
|
+
args.push('--spawn-subagent');
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
// Execute the CLI command
|
|
115
|
-
const
|
|
120
|
+
const result = spawnSync('ghp', args, {
|
|
116
121
|
encoding: 'utf-8',
|
|
117
122
|
cwd: process.cwd(),
|
|
118
123
|
env: process.env,
|
|
119
124
|
});
|
|
120
125
|
|
|
126
|
+
if (result.status !== 0) {
|
|
127
|
+
throw new Error(result.stderr || `ghp start failed with exit code ${result.status}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const output = result.stdout;
|
|
131
|
+
|
|
121
132
|
// Parse the spawn directive if present
|
|
122
133
|
const directive = spawnSubagent ? parseSpawnDirective(output) : null;
|
|
123
134
|
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['src/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
include: ['src/**/*.ts'],
|
|
11
|
+
exclude: ['src/**/*.test.ts', 'src/index.ts'],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|