@ezmodo/mcp-server 0.13.4 → 0.14.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/handlers/auth.js +160 -0
- package/handlers/index.js +2 -0
- package/http.js +11 -3
- package/index.js +21 -52
- package/lib/auth-guidance.js +67 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +79 -7
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +19 -6
- package/lib/instructions.generated.js +14 -0
- package/lib/instructions.js +37 -0
- package/lib/oauth-config.js +98 -0
- package/lib/oauth.js +353 -0
- package/lib/remote-tools.js +157 -0
- package/lib/token-store.js +136 -0
- package/lib/user-paths.js +41 -0
- package/lib/version.js +1 -1
- package/package.json +9 -6
- package/prompts/commands.generated.js +52 -0
- package/prompts/index.js +62 -29
- package/scripts/build-instructions.mjs +76 -0
- package/scripts/build-prompts.mjs +144 -0
- package/tools/auth.js +34 -0
- package/tools/index.js +4 -0
- package/prompts/ai-workflow-automation.js +0 -96
- package/prompts/zephly-usage-guide.js +0 -119
package/lib/git-helpers.js
CHANGED
|
@@ -3,26 +3,60 @@
|
|
|
3
3
|
* Utility functions for git operations used by worktree tools
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Run git with an ARGUMENT ARRAY — never a command string, and never a shell.
|
|
10
|
+
*
|
|
11
|
+
* This used to be `execSync(command)`, with callers building the command by
|
|
12
|
+
* interpolating branch names and paths into a template. #2614 stopped the
|
|
13
|
+
* remote transport from reaching any of it, which removed the internet-facing
|
|
14
|
+
* blast radius; this closes the hole itself, so the same mistake cannot be made
|
|
15
|
+
* again by a future caller who is not thinking about shells.
|
|
16
|
+
*
|
|
17
|
+
* execFileSync spawns git directly, so the arguments are passed as-is: a value
|
|
18
|
+
* containing `;`, a backtick or a quote is a literal branch name that git will
|
|
19
|
+
* simply not find, rather than a second command. Nothing in this file quotes
|
|
20
|
+
* anything any more, because there is no shell left to quote for.
|
|
21
|
+
*
|
|
22
|
+
* @param {string[]} args - Arguments to git, one element per argument
|
|
11
23
|
* @param {string} cwd - Working directory
|
|
12
|
-
* @returns {string} Command output
|
|
24
|
+
* @returns {string} Command output, trimmed
|
|
13
25
|
*/
|
|
14
|
-
export function execGit(
|
|
26
|
+
export function execGit(args, cwd) {
|
|
27
|
+
if (!Array.isArray(args)) {
|
|
28
|
+
throw new TypeError('execGit takes an argument array, not a command string');
|
|
29
|
+
}
|
|
15
30
|
try {
|
|
16
|
-
return
|
|
31
|
+
return execFileSync('git', args, {
|
|
17
32
|
cwd,
|
|
18
33
|
encoding: 'utf-8',
|
|
19
34
|
stdio: 'pipe',
|
|
20
35
|
}).trim();
|
|
21
36
|
} catch (error) {
|
|
22
|
-
throw new Error(`Git command failed: ${
|
|
37
|
+
throw new Error(`Git command failed: git ${args.join(' ')}\n${error.message}`);
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Reject a value that git would read as an OPTION rather than as a name.
|
|
43
|
+
*
|
|
44
|
+
* The residual risk after dropping the shell is not injection, it is option
|
|
45
|
+
* injection: `--exec=...` in a branch-name position is still one argument, and
|
|
46
|
+
* git still honours it. Callers pass user-supplied refs and paths, so the
|
|
47
|
+
* values that land in those positions are checked here.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} value - The candidate branch name, ref or path
|
|
50
|
+
* @param {string} label - What it is, for the error message
|
|
51
|
+
* @returns {string} The value, unchanged
|
|
52
|
+
*/
|
|
53
|
+
function assertNotOption(value, label) {
|
|
54
|
+
if (typeof value !== 'string' || value.startsWith('-')) {
|
|
55
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Check if directory is a git repository
|
|
28
62
|
* @param {string} dir - Directory path
|
|
@@ -30,11 +64,7 @@ export function execGit(command, cwd) {
|
|
|
30
64
|
*/
|
|
31
65
|
export function isGitRepository(dir) {
|
|
32
66
|
try {
|
|
33
|
-
|
|
34
|
-
cwd: dir,
|
|
35
|
-
encoding: 'utf-8',
|
|
36
|
-
stdio: 'pipe',
|
|
37
|
-
});
|
|
67
|
+
execGit(['rev-parse', '--git-dir'], dir);
|
|
38
68
|
return true;
|
|
39
69
|
} catch {
|
|
40
70
|
return false;
|
|
@@ -48,11 +78,7 @@ export function isGitRepository(dir) {
|
|
|
48
78
|
*/
|
|
49
79
|
export function getRepositoryRoot(dir) {
|
|
50
80
|
try {
|
|
51
|
-
return
|
|
52
|
-
cwd: dir,
|
|
53
|
-
encoding: 'utf-8',
|
|
54
|
-
stdio: 'pipe',
|
|
55
|
-
}).trim();
|
|
81
|
+
return execGit(['rev-parse', '--show-toplevel'], dir);
|
|
56
82
|
} catch {
|
|
57
83
|
return null;
|
|
58
84
|
}
|
|
@@ -65,7 +91,7 @@ export function getRepositoryRoot(dir) {
|
|
|
65
91
|
*/
|
|
66
92
|
export function getRemoteUrl(repoPath) {
|
|
67
93
|
try {
|
|
68
|
-
return execGit('
|
|
94
|
+
return execGit(['config', '--get', 'remote.origin.url'], repoPath);
|
|
69
95
|
} catch {
|
|
70
96
|
return null;
|
|
71
97
|
}
|
|
@@ -78,7 +104,7 @@ export function getRemoteUrl(repoPath) {
|
|
|
78
104
|
*/
|
|
79
105
|
export function getCurrentBranch(repoPath) {
|
|
80
106
|
try {
|
|
81
|
-
return execGit('
|
|
107
|
+
return execGit(['branch', '--show-current'], repoPath);
|
|
82
108
|
} catch {
|
|
83
109
|
return null;
|
|
84
110
|
}
|
|
@@ -93,15 +119,17 @@ export function getCurrentBranch(repoPath) {
|
|
|
93
119
|
export function branchExists(repoPath, branchName) {
|
|
94
120
|
const result = { local: false, remote: false };
|
|
95
121
|
|
|
122
|
+
assertNotOption(branchName, 'branch name');
|
|
123
|
+
|
|
96
124
|
try {
|
|
97
|
-
execGit(
|
|
125
|
+
execGit(['show-ref', '--verify', `refs/heads/${branchName}`], repoPath);
|
|
98
126
|
result.local = true;
|
|
99
127
|
} catch {
|
|
100
128
|
// Branch doesn't exist locally
|
|
101
129
|
}
|
|
102
130
|
|
|
103
131
|
try {
|
|
104
|
-
execGit(
|
|
132
|
+
execGit(['show-ref', '--verify', `refs/remotes/origin/${branchName}`], repoPath);
|
|
105
133
|
result.remote = true;
|
|
106
134
|
} catch {
|
|
107
135
|
// Branch doesn't exist remotely
|
|
@@ -118,18 +146,21 @@ export function branchExists(repoPath, branchName) {
|
|
|
118
146
|
* @returns {string} Commit hash
|
|
119
147
|
*/
|
|
120
148
|
export function createBranch(repoPath, branchName, baseBranch) {
|
|
149
|
+
assertNotOption(branchName, 'branch name');
|
|
150
|
+
assertNotOption(baseBranch, 'base branch');
|
|
151
|
+
|
|
121
152
|
// Ensure base branch is up to date
|
|
122
153
|
try {
|
|
123
|
-
execGit(
|
|
154
|
+
execGit(['fetch', 'origin', baseBranch], repoPath);
|
|
124
155
|
} catch {
|
|
125
156
|
// Fetch might fail if no remote, that's ok
|
|
126
157
|
}
|
|
127
158
|
|
|
128
159
|
// Create branch
|
|
129
|
-
execGit(
|
|
160
|
+
execGit(['branch', branchName, baseBranch], repoPath);
|
|
130
161
|
|
|
131
162
|
// Get commit hash
|
|
132
|
-
return execGit(
|
|
163
|
+
return execGit(['rev-parse', branchName], repoPath);
|
|
133
164
|
}
|
|
134
165
|
|
|
135
166
|
/**
|
|
@@ -141,8 +172,23 @@ export function createBranch(repoPath, branchName, baseBranch) {
|
|
|
141
172
|
* @returns {void}
|
|
142
173
|
*/
|
|
143
174
|
export function createWorktree(repoPath, worktreePath, branchName, createBranch = false) {
|
|
144
|
-
|
|
145
|
-
|
|
175
|
+
assertNotOption(worktreePath, 'worktree path');
|
|
176
|
+
assertNotOption(branchName, 'branch name');
|
|
177
|
+
|
|
178
|
+
// `git worktree add -b <new-branch> <path>` — the branch name comes FIRST
|
|
179
|
+
// after -b. The string-building version had it the other way round
|
|
180
|
+
// (`add -b "<path>" <branch>`), so git read the path as the branch name and
|
|
181
|
+
// refused it. Nothing caught that because every caller in worktree-tools.js
|
|
182
|
+
// passes createBranch = false, having created the branch already; this path
|
|
183
|
+
// has never worked. Corrected here rather than left as a trap, since the
|
|
184
|
+
// whole point of moving to an argument array is that the arguments are now
|
|
185
|
+
// legible.
|
|
186
|
+
execGit(
|
|
187
|
+
createBranch
|
|
188
|
+
? ['worktree', 'add', '-b', branchName, worktreePath]
|
|
189
|
+
: ['worktree', 'add', worktreePath, branchName],
|
|
190
|
+
repoPath
|
|
191
|
+
);
|
|
146
192
|
}
|
|
147
193
|
|
|
148
194
|
/**
|
|
@@ -152,7 +198,7 @@ export function createWorktree(repoPath, worktreePath, branchName, createBranch
|
|
|
152
198
|
*/
|
|
153
199
|
export function listWorktrees(repoPath) {
|
|
154
200
|
try {
|
|
155
|
-
const output = execGit('
|
|
201
|
+
const output = execGit(['worktree', 'list', '--porcelain'], repoPath);
|
|
156
202
|
const worktrees = [];
|
|
157
203
|
const lines = output.split('\n');
|
|
158
204
|
|
|
@@ -190,8 +236,9 @@ export function listWorktrees(repoPath) {
|
|
|
190
236
|
* @returns {void}
|
|
191
237
|
*/
|
|
192
238
|
export function removeWorktree(repoPath, worktreePath, force = false) {
|
|
193
|
-
|
|
194
|
-
|
|
239
|
+
assertNotOption(worktreePath, 'worktree path');
|
|
240
|
+
|
|
241
|
+
execGit(['worktree', 'remove', ...(force ? ['--force'] : []), worktreePath], repoPath);
|
|
195
242
|
}
|
|
196
243
|
|
|
197
244
|
/**
|
|
@@ -209,7 +256,7 @@ export function getWorktreeStatus(worktreePath) {
|
|
|
209
256
|
};
|
|
210
257
|
|
|
211
258
|
try {
|
|
212
|
-
const output = execGit('
|
|
259
|
+
const output = execGit(['status', '--porcelain'], worktreePath);
|
|
213
260
|
|
|
214
261
|
if (output) {
|
|
215
262
|
status.isDirty = true;
|
|
@@ -248,19 +295,22 @@ export function getAheadBehindCounts(worktreePath, localBranch, remoteBranch) {
|
|
|
248
295
|
const counts = { ahead: 0, behind: 0 };
|
|
249
296
|
|
|
250
297
|
try {
|
|
298
|
+
assertNotOption(localBranch, 'local branch');
|
|
299
|
+
assertNotOption(remoteBranch, 'remote branch');
|
|
300
|
+
|
|
251
301
|
// Fetch latest
|
|
252
|
-
execGit('
|
|
302
|
+
execGit(['fetch', 'origin'], worktreePath);
|
|
253
303
|
|
|
254
304
|
// Get ahead count
|
|
255
305
|
const aheadOutput = execGit(
|
|
256
|
-
|
|
306
|
+
['rev-list', '--count', `${remoteBranch}..${localBranch}`],
|
|
257
307
|
worktreePath
|
|
258
308
|
);
|
|
259
309
|
counts.ahead = parseInt(aheadOutput, 10) || 0;
|
|
260
310
|
|
|
261
311
|
// Get behind count
|
|
262
312
|
const behindOutput = execGit(
|
|
263
|
-
|
|
313
|
+
['rev-list', '--count', `${localBranch}..${remoteBranch}`],
|
|
264
314
|
worktreePath
|
|
265
315
|
);
|
|
266
316
|
counts.behind = parseInt(behindOutput, 10) || 0;
|
|
@@ -280,7 +330,7 @@ export function checkForConflicts(worktreePath) {
|
|
|
280
330
|
const result = { hasConflicts: false, files: [] };
|
|
281
331
|
|
|
282
332
|
try {
|
|
283
|
-
const output = execGit('
|
|
333
|
+
const output = execGit(['status', '--porcelain'], worktreePath);
|
|
284
334
|
const lines = output.split('\n').filter(l => l);
|
|
285
335
|
|
|
286
336
|
for (const line of lines) {
|
|
@@ -310,8 +360,9 @@ export function checkForConflicts(worktreePath) {
|
|
|
310
360
|
* @returns {void}
|
|
311
361
|
*/
|
|
312
362
|
export function deleteBranch(repoPath, branchName, force = false) {
|
|
313
|
-
|
|
314
|
-
|
|
363
|
+
assertNotOption(branchName, 'branch name');
|
|
364
|
+
|
|
365
|
+
execGit(['branch', force ? '-D' : '-d', branchName], repoPath);
|
|
315
366
|
}
|
|
316
367
|
|
|
317
368
|
/**
|
|
@@ -323,7 +374,9 @@ export function deleteBranch(repoPath, branchName, force = false) {
|
|
|
323
374
|
*/
|
|
324
375
|
export function isBranchMerged(repoPath, branchName, baseBranch) {
|
|
325
376
|
try {
|
|
326
|
-
|
|
377
|
+
assertNotOption(baseBranch, 'base branch');
|
|
378
|
+
|
|
379
|
+
const output = execGit(['branch', '--merged', baseBranch], repoPath);
|
|
327
380
|
return output.includes(branchName);
|
|
328
381
|
} catch {
|
|
329
382
|
return false;
|
|
@@ -338,10 +391,12 @@ export function isBranchMerged(repoPath, branchName, baseBranch) {
|
|
|
338
391
|
*/
|
|
339
392
|
export function getLastCommit(repoPath, branchName) {
|
|
340
393
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
394
|
+
assertNotOption(branchName, 'branch name');
|
|
395
|
+
|
|
396
|
+
const hash = execGit(['rev-parse', branchName], repoPath);
|
|
397
|
+
const author = execGit(['log', '-1', '--format=%an', branchName], repoPath);
|
|
398
|
+
const timestamp = execGit(['log', '-1', '--format=%ct', branchName], repoPath);
|
|
399
|
+
const message = execGit(['log', '-1', '--format=%s', branchName], repoPath);
|
|
345
400
|
|
|
346
401
|
return {
|
|
347
402
|
hash,
|
|
@@ -397,7 +452,10 @@ export function generateBranchName(task, defaultPrefix = 'feature') {
|
|
|
397
452
|
*/
|
|
398
453
|
export function getMergeBase(repoPath, branch1, branch2) {
|
|
399
454
|
try {
|
|
400
|
-
|
|
455
|
+
assertNotOption(branch1, 'branch');
|
|
456
|
+
assertNotOption(branch2, 'branch');
|
|
457
|
+
|
|
458
|
+
return execGit(['merge-base', branch1, branch2], repoPath);
|
|
401
459
|
} catch {
|
|
402
460
|
return null;
|
|
403
461
|
}
|
|
@@ -411,16 +469,16 @@ export function getMergeBase(repoPath, branch1, branch2) {
|
|
|
411
469
|
export function getDefaultBranch(repoPath) {
|
|
412
470
|
// Try to get from remote HEAD
|
|
413
471
|
try {
|
|
414
|
-
const ref = execGit('
|
|
472
|
+
const ref = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
|
|
415
473
|
return ref.replace('refs/remotes/origin/', '');
|
|
416
474
|
} catch {
|
|
417
475
|
// Fall back to checking if main or master exists
|
|
418
476
|
try {
|
|
419
|
-
execGit('
|
|
477
|
+
execGit(['show-ref', '--verify', 'refs/heads/main'], repoPath);
|
|
420
478
|
return 'main';
|
|
421
479
|
} catch {
|
|
422
480
|
try {
|
|
423
|
-
execGit('
|
|
481
|
+
execGit(['show-ref', '--verify', 'refs/heads/master'], repoPath);
|
|
424
482
|
return 'master';
|
|
425
483
|
} catch {
|
|
426
484
|
return 'main'; // Default fallback
|
|
@@ -439,13 +497,17 @@ export function getDefaultBranch(repoPath) {
|
|
|
439
497
|
export function getCommitsSince(repoPath, sinceCommit, branchName) {
|
|
440
498
|
try {
|
|
441
499
|
// Format: hash|short|author|email|timestamp|subject
|
|
500
|
+
assertNotOption(branchName, 'branch name');
|
|
501
|
+
if (sinceCommit) {
|
|
502
|
+
assertNotOption(sinceCommit, 'commit');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// No quotes around the format: there is no shell to strip them, so a
|
|
506
|
+
// quoted format string would reach git verbatim and appear in the output.
|
|
442
507
|
const format = '%H|%h|%an|%ae|%ct|%s';
|
|
443
508
|
const range = sinceCommit ? `${sinceCommit}..${branchName}` : branchName;
|
|
444
509
|
|
|
445
|
-
const output = execGit(
|
|
446
|
-
`git log --format="${format}" ${range}`,
|
|
447
|
-
repoPath
|
|
448
|
-
);
|
|
510
|
+
const output = execGit(['log', `--format=${format}`, range], repoPath);
|
|
449
511
|
|
|
450
512
|
if (!output) {
|
|
451
513
|
return [];
|
|
@@ -469,8 +531,9 @@ export function getCommitsSince(repoPath, sinceCommit, branchName) {
|
|
|
469
531
|
}
|
|
470
532
|
|
|
471
533
|
/**
|
|
472
|
-
* A commit SHA, and nothing else.
|
|
473
|
-
*
|
|
534
|
+
* A commit SHA, and nothing else. Kept after execGit stopped using a shell
|
|
535
|
+
* (#2614 step 5): it is still the cheapest way to tell a real hash from a ref
|
|
536
|
+
* that would make diff-tree mean something other than "this commit".
|
|
474
537
|
*/
|
|
475
538
|
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
476
539
|
|
|
@@ -497,7 +560,7 @@ export function getCommitFiles(repoPath, sha) {
|
|
|
497
560
|
}
|
|
498
561
|
try {
|
|
499
562
|
const output = execGit(
|
|
500
|
-
|
|
563
|
+
['diff-tree', '--no-commit-id', '--name-only', '-r', sha],
|
|
501
564
|
repoPath
|
|
502
565
|
);
|
|
503
566
|
return output ? output.split('\n').map(line => line.trim()).filter(Boolean) : [];
|
package/lib/http-client.js
CHANGED
|
@@ -10,11 +10,13 @@ import { CONFIG } from '../config/index.js';
|
|
|
10
10
|
import { MCP_VERSION } from './version.js';
|
|
11
11
|
import { getLogger } from './logger.js';
|
|
12
12
|
import { getApiUrl } from './env.js';
|
|
13
|
-
// Not getApiKey() directly
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
13
|
+
// Not getApiKey() directly. Two reasons, both about the credential belonging to
|
|
14
|
+
// the CALL rather than the process: over HTTP one process serves many callers,
|
|
15
|
+
// each with their own key (#2599); and over stdio the credential may be an
|
|
16
|
+
// OAuth token this server obtained for itself, which can need refreshing before
|
|
17
|
+
// use (#2631). lib/credentials.js owns the precedence between them.
|
|
18
|
+
import { resolveCredential } from './credentials.js';
|
|
19
|
+
import { NOT_AUTHENTICATED } from './auth-guidance.js';
|
|
18
20
|
|
|
19
21
|
// API base URL is resolved per-request (see callZephlyAPI): getApiUrl() honors
|
|
20
22
|
// the EZMODO_API_URL / ZEPHLY_API_URL override; CONFIG.apiUrl is the build-time
|
|
@@ -82,8 +84,19 @@ export async function callZephlyAPI(endpoint, data) {
|
|
|
82
84
|
let url = `${apiUrl}/${route}`;
|
|
83
85
|
let body = null;
|
|
84
86
|
|
|
87
|
+
// Resolved per call, and awaited: an expired OAuth token refreshes here.
|
|
88
|
+
const credential = await resolveCredential();
|
|
89
|
+
if (!credential) {
|
|
90
|
+
// Coded, not just worded: lib/create-server.js turns this into sign-in
|
|
91
|
+
// guidance for the agent, and matching on a message would break the first
|
|
92
|
+
// time someone reworded it.
|
|
93
|
+
const error = new Error('Not authenticated with EzModo.');
|
|
94
|
+
error.code = NOT_AUTHENTICATED;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
const headers = {
|
|
86
|
-
'Authorization': `Bearer ${
|
|
99
|
+
'Authorization': `Bearer ${credential.token}`,
|
|
87
100
|
'Content-Type': 'application/json',
|
|
88
101
|
'User-Agent': `ezmodo-mcp-server/${MCP_VERSION}`,
|
|
89
102
|
'X-MCP-API-Version': 'v1',
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from plugins/ezmodo/skills/work-tracking/SKILL.md by
|
|
5
|
+
* scripts/build-instructions.mjs. Edit the SKILL, then run:
|
|
6
|
+
*
|
|
7
|
+
* npm run generate:instructions --workspace=@ezmodo/mcp-server
|
|
8
|
+
*
|
|
9
|
+
* __tests__/instructions.test.js fails if this drifts from the skill.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const WORK_TRACKING_CORE = 'Work in this repository is tracked in EzModo, and the tools for it are on this\nMCP server. The contract, in short:\n\n1. **Create the task before you edit, not after.** A task written afterwards is\n a changelog; a task written first is what the next session reads to find out\n what you were doing and why.\n2. **Start the session by calling `get_current_project_context()`.** Cache the\n `projectId`. It also returns the components, tags and `terminology` you need.\n No `.ezmodo/config.json` means this repo is not tracked — say so rather than\n guessing at a project.\n3. **Call `get_context` with a keyword query before creating anything.** Use\n what comes back to write a task that names real files, endpoints and\n patterns. A vague task is not worth the call that made it.\n4. **Name every component the work touches** via `componentIds`. A task spanning\n web and api belongs to both. The list REPLACES the previous set on update.\n5. **Set `taskType`** — `feature` | `bug` | `testing` | `chore`. Not cosmetic:\n `bug` feeds open-bug counts, milestone freezes gate on it, and estimation\n weights past tasks of the same kind.\n6. **Toggle steps as you finish them**, not in a batch at the end\n (`manage_task action:"update" toggleSteps:[...]`).\n7. **Capture knowledge the moment it happens**, not in a summary at the end:\n `addKnowledge` with `fact` for a root cause, `decision` for a choice —\n including what you rejected and why — `reference` for a key file or pattern,\n `context` for progress. Be specific: file paths, function names, exact\n errors. "Fixed a bug in the parser" helps nobody.\n8. **Already three edits in with no task?** Call `report_untracked_work` the\n moment you notice, rather than continuing untracked.\n9. **Resuming?** `get_task` first, and read ALL of its knowledge items. That is\n where the previous session\'s reasoning went — do not re-derive it.\n10. **Finish at `in_review`** with `completionNotes`, and do NOT call\n `action:"complete"`. A human completes a task after verifying it.\n11. **Report what actually happened.** A task moved to `in_review` claiming work\n that was not done is worse than no task, because the next session trusts it.\n\nRespect the project\'s `terminology`: a project can rename epics, tasks and\ncomponents, and a marketing project calls an epic a "Campaign". Write anything a\nhuman reads in those words; keep API field names (`epicId`, `taskId`) as they\nare.';
|
|
13
|
+
|
|
14
|
+
export const WORK_TRACKING_LOCAL = 'Running against a local checkout, two more:\n\n12. **Link every commit**: `manage_task action:"link_commit"` with the full\n 40-character `sha` from `git rev-parse HEAD`. A short SHA is rejected, and\n padding one is not a fix. Linking is also what derives component links from\n the commit\'s files — do not link those by hand.\n13. **Pass `changedFiles`** when creating or updating a task, so the work\n resolves to the components that own those paths.';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP `instructions` string — what the server tells every client about how
|
|
3
|
+
* to work with it (#2633).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS MATTERS OUT OF PROPORTION TO ITS SIZE. The tools were always the
|
|
6
|
+
* easy half. What makes an agent actually TRACK work — create a task before you
|
|
7
|
+
* edit, tick steps off, capture knowledge, link the commit, finish at
|
|
8
|
+
* in_review — shipped only as Claude Code skills in plugins/ezmodo/skills/.
|
|
9
|
+
* Cursor, Codex, Zed and every other stdio MCP client got the tools and none of
|
|
10
|
+
* the discipline. The initialize result carries an instructions string that
|
|
11
|
+
* clients inject into system context, so this reaches all of them with no
|
|
12
|
+
* plugin, no per-editor packaging, and no install step.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT IS SHORT. A skill is loaded on demand and can afford several hundred
|
|
15
|
+
* lines. Instructions are paid for on every session, so this is the irreducible
|
|
16
|
+
* core and nothing else. The full contract stays in the skill, and the two are
|
|
17
|
+
* the same words — see scripts/build-instructions.mjs for how, and why they are
|
|
18
|
+
* not two hand-maintained copies.
|
|
19
|
+
*
|
|
20
|
+
* WHY IT VARIES BY SURFACE. The remote connector serves no local-machine tools,
|
|
21
|
+
* so telling an agent there to link commits and pass changed files is advice it
|
|
22
|
+
* cannot follow — worse than silence, because an agent that tries will report a
|
|
23
|
+
* failure the user cannot act on. The local block is appended only for stdio,
|
|
24
|
+
* on the same `surface` parameter that filters the tool list.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { WORK_TRACKING_CORE, WORK_TRACKING_LOCAL } from './instructions.generated.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {'local'|'remote'} [surface]
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function getInstructions(surface = 'local') {
|
|
34
|
+
return surface === 'remote'
|
|
35
|
+
? WORK_TRACKING_CORE
|
|
36
|
+
: `${WORK_TRACKING_CORE}\n\n${WORK_TRACKING_LOCAL}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keycloak OIDC configuration for the local (stdio) MCP server.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE SERVER LOGS IN AT ALL. Installing used to be two steps, and the
|
|
5
|
+
* second one is the cumbersome one: install the server, then leave the agent,
|
|
6
|
+
* open the web UI, mint an API key, paste it into a shell profile, restart.
|
|
7
|
+
* That step is identical for Cursor, Codex, Zed or anything else speaking
|
|
8
|
+
* stdio MCP, and it is what makes people reach for the CLI. The server signs
|
|
9
|
+
* itself in instead, so installing IS the whole install (E-252 #2631).
|
|
10
|
+
*
|
|
11
|
+
* WHY `ezmodo-mcp` AND NOT `ezmodo-cli`. Both are public PKCE clients in the
|
|
12
|
+
* `ezmodo` realm and either would authenticate, since the API skips the
|
|
13
|
+
* audience check (KEYCLOAK_CLIENT_ID is deliberately empty in
|
|
14
|
+
* infra/cloud-run-api-ezmodo.tf). Three things decide it:
|
|
15
|
+
*
|
|
16
|
+
* 1. Redirect URIs. `ezmodo-cli` registers ONE fixed loopback,
|
|
17
|
+
* http://localhost:19838/callback. A fixed port fails when it is already
|
|
18
|
+
* taken — including by an `ezmodo auth login` running in another terminal,
|
|
19
|
+
* which is exactly when a user is most likely to be signing in.
|
|
20
|
+
* `ezmodo-mcp` registers http://localhost/* and http://127.0.0.1/*, which
|
|
21
|
+
* matches ANY ephemeral port: Keycloak's RedirectUtils, on a failed match
|
|
22
|
+
* for an http URI whose host is a loopback interface, rebuilds it with
|
|
23
|
+
* port 80 and re-matches.
|
|
24
|
+
* 2. Consent. `ezmodo-mcp` sets consent_required. The whole argument for
|
|
25
|
+
* preferring OAuth over a pasted key is that the user SEES what they are
|
|
26
|
+
* granting; a client that skips the consent screen throws that away.
|
|
27
|
+
* 3. It is what this is. `ezmodo-mcp` is the MCP client, and the scopes a
|
|
28
|
+
* human reads on the consent screen are attached to it.
|
|
29
|
+
*
|
|
30
|
+
* The client id is baked in, not pasted. That is why Keycloak's refusal of
|
|
31
|
+
* anonymous Dynamic Client Registration — the thing that makes a claude.ai
|
|
32
|
+
* connector user paste `ezmodo-mcp` by hand — does not touch a local install
|
|
33
|
+
* at all.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { CONFIG } from '../config/index.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Keycloak per environment. Mirrors cli/src/lib/config.ts KEYCLOAK_CONFIG; the
|
|
40
|
+
* realm is `ezmodo` everywhere, including local, so a client that works on a
|
|
41
|
+
* laptop works in production.
|
|
42
|
+
*/
|
|
43
|
+
const KEYCLOAK = {
|
|
44
|
+
production: { url: 'https://auth.ezmodo.com', realm: 'ezmodo' },
|
|
45
|
+
staging: { url: 'https://auth.staging.ezmodo.com', realm: 'ezmodo' },
|
|
46
|
+
development: { url: 'http://localhost:7373', realm: 'ezmodo' },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The public PKCE client this server authenticates as. */
|
|
50
|
+
export const OAUTH_CLIENT_ID = 'ezmodo-mcp';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What we ask consent for, and deliberately NOT everything on offer.
|
|
54
|
+
*
|
|
55
|
+
* `ezmodo:delete` is a registered optional scope and is left out by default.
|
|
56
|
+
* Keycloak's consent screen is accept-or-decline over the whole requested set,
|
|
57
|
+
* so asking for it would make "permanently delete your tasks, documents, goals
|
|
58
|
+
* and projects" a condition of installing an MCP server — which is not a
|
|
59
|
+
* decision to bundle into a setup step someone is clicking through. The MCP
|
|
60
|
+
* tool surface is overwhelmingly create/update anyway; almost nothing here
|
|
61
|
+
* deletes.
|
|
62
|
+
*
|
|
63
|
+
* `offline_access` is what makes the refresh token outlive the short access
|
|
64
|
+
* token, i.e. what stops this asking for a browser every few minutes.
|
|
65
|
+
*
|
|
66
|
+
* Override with EZMODO_OAUTH_SCOPES (space-separated) to widen or narrow it —
|
|
67
|
+
* that is the escape hatch for someone who genuinely wants delete, without
|
|
68
|
+
* making it everyone's default.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_SCOPES = 'openid email profile offline_access ezmodo:read ezmodo:write';
|
|
71
|
+
|
|
72
|
+
/** The scopes to request, honouring the override. */
|
|
73
|
+
export function getScopes() {
|
|
74
|
+
const override = process.env.EZMODO_OAUTH_SCOPES;
|
|
75
|
+
return override && override.trim() ? override.trim() : DEFAULT_SCOPES;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keycloak endpoints for the environment this build targets.
|
|
80
|
+
*
|
|
81
|
+
* Falls back to production for an unrecognised environment for the same reason
|
|
82
|
+
* config/index.js defaults BUILD_ENV to production: the only caller that
|
|
83
|
+
* arrives here with nothing set is an installed copy on a real user's machine,
|
|
84
|
+
* and pointing that at localhost fails with an error saying nothing about why.
|
|
85
|
+
*/
|
|
86
|
+
export function getKeycloakEndpoints() {
|
|
87
|
+
const env = KEYCLOAK[CONFIG.environment] ? CONFIG.environment : 'production';
|
|
88
|
+
const { url, realm } = KEYCLOAK[env];
|
|
89
|
+
const base = `${url}/realms/${realm}/protocol/openid-connect`;
|
|
90
|
+
return {
|
|
91
|
+
environment: env,
|
|
92
|
+
issuer: `${url}/realms/${realm}`,
|
|
93
|
+
authorization: `${base}/auth`,
|
|
94
|
+
token: `${base}/token`,
|
|
95
|
+
logout: `${base}/logout`,
|
|
96
|
+
clientId: OAUTH_CLIENT_ID,
|
|
97
|
+
};
|
|
98
|
+
}
|