@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.
@@ -3,26 +3,60 @@
3
3
  * Utility functions for git operations used by worktree tools
4
4
  */
5
5
 
6
- import { execSync } from 'child_process';
6
+ import { execFileSync } from 'child_process';
7
7
 
8
8
  /**
9
- * Execute git command with proper error handling
10
- * @param {string} command - Git command to execute
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(command, cwd) {
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 execSync(command, {
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: ${command}\n${error.message}`);
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
- execSync('git rev-parse --git-dir', {
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 execSync('git rev-parse --show-toplevel', {
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('git config --get remote.origin.url', repoPath);
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('git branch --show-current', repoPath);
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(`git show-ref --verify refs/heads/${branchName}`, repoPath);
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(`git show-ref --verify refs/remotes/origin/${branchName}`, repoPath);
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(`git fetch origin ${baseBranch}`, repoPath);
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(`git branch ${branchName} ${baseBranch}`, repoPath);
160
+ execGit(['branch', branchName, baseBranch], repoPath);
130
161
 
131
162
  // Get commit hash
132
- return execGit(`git rev-parse ${branchName}`, repoPath);
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
- const branchFlag = createBranch ? '-b' : '';
145
- execGit(`git worktree add ${branchFlag} "${worktreePath}" ${branchName}`, repoPath);
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('git worktree list --porcelain', repoPath);
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
- const forceFlag = force ? '--force' : '';
194
- execGit(`git worktree remove ${forceFlag} "${worktreePath}"`, repoPath);
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('git status --porcelain', worktreePath);
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('git fetch origin', worktreePath);
302
+ execGit(['fetch', 'origin'], worktreePath);
253
303
 
254
304
  // Get ahead count
255
305
  const aheadOutput = execGit(
256
- `git rev-list --count ${remoteBranch}..${localBranch}`,
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
- `git rev-list --count ${localBranch}..${remoteBranch}`,
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('git status --porcelain', worktreePath);
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
- const flag = force ? '-D' : '-d';
314
- execGit(`git branch ${flag} ${branchName}`, repoPath);
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
- const output = execGit(`git branch --merged ${baseBranch}`, repoPath);
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
- const hash = execGit(`git rev-parse ${branchName}`, repoPath);
342
- const author = execGit(`git log -1 --format=%an ${branchName}`, repoPath);
343
- const timestamp = execGit(`git log -1 --format=%ct ${branchName}`, repoPath);
344
- const message = execGit(`git log -1 --format=%s ${branchName}`, repoPath);
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
- return execGit(`git merge-base ${branch1} ${branch2}`, repoPath);
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('git symbolic-ref refs/remotes/origin/HEAD', repoPath);
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('git show-ref --verify refs/heads/main', repoPath);
477
+ execGit(['show-ref', '--verify', 'refs/heads/main'], repoPath);
420
478
  return 'main';
421
479
  } catch {
422
480
  try {
423
- execGit('git show-ref --verify refs/heads/master', repoPath);
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. The value reaches execGit inside a shell
473
- * string, so anything that is not hex never gets there.
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
- `git diff-tree --no-commit-id --name-only -r ${sha}`,
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) : [];
@@ -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: over HTTP one process serves many callers, so the
14
- // credential belongs to the request in flight rather than the process (#2599).
15
- // Over stdio there is no request context and this resolves to the environment,
16
- // exactly as before.
17
- import { resolveApiKey } from './request-context.js';
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 ${resolveApiKey()}`,
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
+ }