@ezmodo/mcp-server 0.14.1 → 0.14.3

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/README.md CHANGED
@@ -11,7 +11,7 @@ Point any MCP client at the package. There is nothing to configure:
11
11
  "mcpServers": {
12
12
  "ezmodo": {
13
13
  "command": "npx",
14
- "args": ["-y", "@ezmodo/mcp-server"]
14
+ "args": ["-y", "-p", "@ezmodo/mcp-server", "ezmodo-mcp-server"]
15
15
  }
16
16
  }
17
17
  }
@@ -65,7 +65,7 @@ For CI, containers and anything headless where no browser exists, set
65
65
  "mcpServers": {
66
66
  "ezmodo": {
67
67
  "command": "npx",
68
- "args": ["-y", "@ezmodo/mcp-server"],
68
+ "args": ["-y", "-p", "@ezmodo/mcp-server", "ezmodo-mcp-server"],
69
69
  "env": {
70
70
  "EZMODO_API_KEY": "ezm_sk_your_key_here"
71
71
  }
package/handlers/auth.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { beginLogin, getSignedInIdentity, signOut } from '../lib/oauth.js';
21
21
  import { getApiKey } from '../lib/env.js';
22
22
  import { signInRequired } from '../lib/auth-guidance.js';
23
- import { resetCredentialCache } from '../lib/credentials.js';
23
+ import { describeCliCredential, resetCredentialCache } from '../lib/credentials.js';
24
24
  import { getLogger } from '../lib/logger.js';
25
25
 
26
26
  /**
@@ -73,7 +73,23 @@ async function openBrowser(url) {
73
73
  return false;
74
74
  }
75
75
 
76
- async function login() {
76
+ /**
77
+ * Start (or rejoin) a browser sign-in and return the payload that hands its URL
78
+ * to the agent.
79
+ *
80
+ * Exported because it has two callers: the `authenticate` tool, and the
81
+ * dispatch funnel in lib/create-server.js, which calls it on the FIRST call that
82
+ * finds no credential (#2654). Before that, the first call only said "call
83
+ * `authenticate`", and the URL took a second round trip to appear.
84
+ *
85
+ * @param {object} [options]
86
+ * @param {string} [options.reason] Why sign-in is needed, when it was not asked for.
87
+ */
88
+ export async function startSignIn({ reason } = {}) {
89
+ return login(reason);
90
+ }
91
+
92
+ async function login(reason) {
77
93
  const log = getLogger();
78
94
 
79
95
  if (pending) {
@@ -104,7 +120,9 @@ async function login() {
104
120
  return {
105
121
  ...signInRequired({
106
122
  authUrl: flow.authUrl,
107
- reason: 'Sign-in started. Waiting for you to approve it in a browser.',
123
+ reason: reason
124
+ ? `${reason} Sign-in started — waiting for approval in a browser.`
125
+ : 'Sign-in started. Waiting for you to approve it in a browser.',
108
126
  }),
109
127
  browserOpened,
110
128
  next_step: browserOpened
@@ -128,6 +146,23 @@ function status() {
128
146
  return { authenticated: true, source: 'OAuth', ...identity };
129
147
  }
130
148
 
149
+ // Same order as lib/credentials.js resolveCredential(): the CLI's key is
150
+ // used when there is nothing else, so status must say so (#2655).
151
+ const cli = describeCliCredential();
152
+ if (cli) {
153
+ return {
154
+ authenticated: true,
155
+ source: cli.source,
156
+ keyPrefix: cli.keyPrefix,
157
+ note: cli.legacy
158
+ ? 'Calls are using an API key left in the Keychain by the pre-rebrand zephly CLI. ' +
159
+ 'Run `authenticate` to sign in with a browser instead — that takes precedence — ' +
160
+ 'or `ezmodo auth login` to replace the old entry.'
161
+ : 'Calls are using the API key the ezmodo CLI stored. A browser sign-in via ' +
162
+ '`authenticate` would take precedence over it.',
163
+ };
164
+ }
165
+
131
166
  return signInRequired({ reason: 'This server is not signed in.' });
132
167
  }
133
168
 
package/index.js CHANGED
@@ -52,7 +52,7 @@ if (credential) {
52
52
  // happens to be logged in as, with no way to tell, is worse than one that fails.
53
53
  console.error(` Credential: ${credential.detail ?? ''} (from ${credential.source})`);
54
54
  } else {
55
- console.error(' Credential: none yet — sign in with the `authenticate` tool');
55
+ console.error(' Credential: none yet — the first tool call starts a browser sign-in');
56
56
  console.error(` Or set EZMODO_API_KEY. Generate a key at: ${CONFIG.settingsUrl}`);
57
57
  }
58
58
  console.error('');
@@ -54,7 +54,7 @@ const CAVEATS = [
54
54
  'A brand-new EzModo account belongs to no organization yet, so tools will ' +
55
55
  'return 403 until it does. That is an access problem, not a sign-in ' +
56
56
  'problem — signing in again will not change it. The call that hits it ' +
57
- 'says how to fix it; see `organizationRequired` below.',
57
+ 'returns an `onboardingUrl` to finish setting up a workspace.',
58
58
  ];
59
59
 
60
60
  /**
@@ -70,8 +70,15 @@ export function signInRequired({ authUrl, reason } = {}) {
70
70
  reason: reason || 'No EzModo credential is available.',
71
71
  ...(authUrl
72
72
  ? {
73
- action_required: 'Open this URL in a browser, approve the access, then retry the call.',
73
+ action_required: 'Show this URL to the user. Once they have approved it in the browser, retry the call.',
74
74
  authUrl,
75
+ // The tab can vanish without the user doing anything (#2654): with an
76
+ // existing EzModo browser session there is no login or consent screen,
77
+ // so it goes straight to "Signed in". Someone who closes it thinking
78
+ // nothing happened is usually already signed in.
79
+ if_unsure: 'If the browser tab closed, or showed "Signed in" without asking anything, ' +
80
+ 'sign-in has probably already completed — retry the call, or run `authenticate` ' +
81
+ 'with action "status" to see which account.',
75
82
  }
76
83
  : {
77
84
  action_required:
@@ -56,6 +56,12 @@ function fromCredentialsFile() {
56
56
  */
57
57
  function fromMacKeychain() {
58
58
  if (process.platform !== 'darwin') return null;
59
+ // `zephly-cli` is still read because the CLI still reads it
60
+ // (LEGACY_SERVICE_NAME in cli/src/lib/auth-store.ts): dropping it here alone
61
+ // would have the server report "not signed in" to someone `ezmodo auth
62
+ // status` calls signed in. But it is REPORTED as legacy (#2655) — a
63
+ // pre-rebrand key silently answering for a new install is how a developer
64
+ // machine went months without ever exercising the OAuth path customers get.
59
65
  for (const service of ['ezmodo-cli', 'zephly-cli']) {
60
66
  try {
61
67
  const key = execFileSync(
@@ -63,7 +69,7 @@ function fromMacKeychain() {
63
69
  ['find-generic-password', '-s', service, '-a', 'api-key', '-w'],
64
70
  { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }
65
71
  ).trim();
66
- if (key) return key;
72
+ if (key) return { key, legacy: service === 'zephly-cli' };
67
73
  } catch {
68
74
  // Not stored under this name, no `security` binary, denied, or timed
69
75
  // out. All of them mean the same thing here: try the next, then stop.
@@ -84,8 +90,16 @@ export function readCliCredential() {
84
90
  const fileKey = fromCredentialsFile();
85
91
  if (fileKey) return { key: fileKey, source: 'ezmodo CLI credentials file' };
86
92
 
87
- const keychainKey = fromMacKeychain();
88
- if (keychainKey) return { key: keychainKey, source: 'macOS Keychain (ezmodo CLI)' };
93
+ const keychain = fromMacKeychain();
94
+ if (keychain) {
95
+ return keychain.legacy
96
+ ? {
97
+ key: keychain.key,
98
+ source: 'macOS Keychain (legacy zephly-cli entry)',
99
+ legacy: true,
100
+ }
101
+ : { key: keychain.key, source: 'macOS Keychain (ezmodo CLI)' };
102
+ }
89
103
  } catch {
90
104
  // Belt and braces. Nothing above should throw, and if something does, a
91
105
  // missing fallback must not take down the server.
@@ -35,6 +35,8 @@ import {
35
35
  signInRequired,
36
36
  } from './auth-guidance.js';
37
37
  import { getInstructions } from './instructions.js';
38
+ import { getApiKey } from './env.js';
39
+ import { startSignIn as defaultStartSignIn } from '../handlers/auth.js';
38
40
 
39
41
  /**
40
42
  * @param {object} [options]
@@ -81,7 +83,7 @@ function isEmailAlreadyRegistered(error) {
81
83
  return error?.code === EMAIL_ALREADY_REGISTERED;
82
84
  }
83
85
 
84
- export function createServer({ surface = 'local' } = {}) {
86
+ export function createServer({ surface = 'local', startSignIn = defaultStartSignIn } = {}) {
85
87
  const log = getLogger();
86
88
 
87
89
  // Filtered ONCE here rather than at each call site, so listing and dispatch
@@ -144,11 +146,40 @@ export function createServer({ surface = 'local' } = {}) {
144
146
  // every tool goes through it, so none of them can be missed or drift
145
147
  // (#2632). Only on the local surface; over the connector Claude owns the
146
148
  // OAuth and this advice would be wrong.
149
+ //
150
+ // Two changes from the first version (#2654), both from its first real
151
+ // run:
152
+ //
153
+ // - It STARTS the sign-in, instead of telling the agent to call
154
+ // `authenticate`. The URL is what the user needs, and making it take a
155
+ // second tool call bought nothing.
156
+ // - It is NOT flagged isError. Nothing is broken; the user has something
157
+ // to do. Clients differ in how they treat an error result — some drop
158
+ // its text or report the call as failed — and this text is the one
159
+ // thing that must reach the user.
160
+ //
161
+ // Except when EZMODO_API_KEY was the credential that got rejected: an
162
+ // explicit key outranks OAuth, so a browser sign-in could not take
163
+ // effect, and opening one would send the user on an errand that cannot
164
+ // work. That case says so, and stays an error.
147
165
  if (surface !== 'remote' && isAuthFailure(error)) {
148
- return {
149
- content: [{ type: 'text', text: JSON.stringify(signInRequired({ reason: errMsg }), null, 2) }],
150
- isError: true,
151
- };
166
+ if (getApiKey()) {
167
+ return {
168
+ content: [{ type: 'text', text: JSON.stringify(signInRequired({
169
+ reason: `EZMODO_API_KEY was rejected (${errMsg}). It takes precedence over a browser ` +
170
+ 'sign-in, so fix or unset it — signing in will not help while it is set.',
171
+ }), null, 2) }],
172
+ isError: true,
173
+ };
174
+ }
175
+ let payload;
176
+ try {
177
+ payload = await startSignIn({ reason: 'Not signed in to EzModo.' });
178
+ } catch (signInError) {
179
+ log.warn('Could not start sign-in from the dispatch funnel', { error: signInError.message });
180
+ payload = signInRequired({ reason: errMsg });
181
+ }
182
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
152
183
  }
153
184
  // Answered on EVERY surface, unlike the sign-in prompt above. Sign-in
154
185
  // advice is surface-specific because over the connector Claude owns the
@@ -100,6 +100,26 @@ export function describeCredentialSync() {
100
100
  return null;
101
101
  }
102
102
 
103
+ /**
104
+ * The borrowed CLI credential, without the key, for `authenticate status`.
105
+ *
106
+ * Status used to look only at EZMODO_API_KEY and its own OAuth tokens, so with
107
+ * nothing but a CLI key present it said "not signed in" while every call
108
+ * succeeded as the CLI's user (#2655). A status that disagrees with what calls
109
+ * actually do is worse than none.
110
+ *
111
+ * @returns {{ source: string, legacy?: boolean, keyPrefix: string }|null}
112
+ */
113
+ export function describeCliCredential() {
114
+ const fromCli = cliCredential();
115
+ if (!fromCli) return null;
116
+ return {
117
+ source: fromCli.source,
118
+ ...(fromCli.legacy ? { legacy: true } : {}),
119
+ keyPrefix: `${fromCli.key.substring(0, 12)}...`,
120
+ };
121
+ }
122
+
103
123
  /** Test seam: forget the memoized CLI lookup. */
104
124
  export function resetCredentialCache() {
105
125
  cachedCliCredential = undefined;
package/lib/logger.js CHANGED
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * MCP Server Logger - Structured JSON Lines logging to disk
3
3
  *
4
- * Writes to ~/.zephly/logs/zephly-YYYY-MM-DD.log
4
+ * Writes to ~/.ezmodo/logs/ezmodo-YYYY-MM-DD.log
5
+ *
6
+ * Moved from ~/.zephly/logs/zephly-*.log (#2655), matching the CLI's
7
+ * currentLogsDir() in cli/src/lib/user-paths.ts. The old directory is NOT
8
+ * migrated or cleaned: these are diagnostics, safe to delete, and a server that
9
+ * reaches into a directory it no longer owns to tidy it is a server that can
10
+ * delete the wrong thing. Nothing is written under the old brand any more.
5
11
  * Daily rotation, 7-day auto-cleanup on init
6
12
  * Async fire-and-forget writes so logging never blocks tool execution
7
13
  */
@@ -11,14 +17,14 @@ import { join } from 'path';
11
17
  import { homedir } from 'os';
12
18
 
13
19
  const RETENTION_DAYS = 7;
14
- const DATE_PATTERN = /^zephly-(\d{4}-\d{2}-\d{2})\.log$/;
20
+ const DATE_PATTERN = /^ezmodo-(\d{4}-\d{2}-\d{2})\.log$/;
15
21
 
16
22
  function getDateString() {
17
23
  return new Date().toISOString().slice(0, 10);
18
24
  }
19
25
 
20
26
  function getLogFilePath(logsDir) {
21
- return join(logsDir, `zephly-${getDateString()}.log`);
27
+ return join(logsDir, `ezmodo-${getDateString()}.log`);
22
28
  }
23
29
 
24
30
  /**
@@ -31,7 +37,7 @@ export function createLogger(options = {}) {
31
37
  const {
32
38
  source = 'mcp',
33
39
  verbose = false,
34
- logsDir = join(homedir(), '.zephly', 'logs'),
40
+ logsDir = join(homedir(), '.ezmodo', 'logs'),
35
41
  } = options;
36
42
 
37
43
  // Ensure logs directory exists (fire-and-forget)
package/lib/oauth.js CHANGED
@@ -77,6 +77,13 @@ function decodeJwtPayload(token) {
77
77
  // Loopback callback capture
78
78
  // ============================================================
79
79
 
80
+ /** The loopback page is HTML built from strings, so anything interpolated is escaped. */
81
+ function escapeHtml(value) {
82
+ return String(value).replace(/[&<>"']/g, (c) => ({
83
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;',
84
+ })[c]);
85
+ }
86
+
80
87
  const DONE_PAGE = (heading, detail) => `<!doctype html>
81
88
  <html lang="en"><head><meta charset="utf-8"><title>EzModo</title>
82
89
  <style>
@@ -84,19 +91,22 @@ const DONE_PAGE = (heading, detail) => `<!doctype html>
84
91
  display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
85
92
  main{text-align:center;max-width:26rem;padding:2rem}
86
93
  h1{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}
87
- p{color:#9aa0a6;line-height:1.5;margin:0}
94
+ p{color:#9aa0a6;line-height:1.5;margin:0 0 .75rem}
95
+ strong{color:#e8eaed;font-weight:600}
88
96
  </style></head>
89
- <body><main><h1>${heading}</h1><p>${detail}</p></main></body></html>`;
97
+ <body><main><h1>${escapeHtml(heading)}</h1>${detail}</main></body></html>`;
90
98
 
91
99
  /**
92
100
  * Listen on an ephemeral loopback port for the authorization redirect.
93
101
  *
94
- * Resolves with the code once one arrives. The redirect URI is returned before
102
+ * Resolves with the code, and a `respond` to answer the browser with, once one
103
+ * arrives. The redirect URI is returned before
95
104
  * the code is, because the caller needs the port to build the authorize URL —
96
105
  * hence the two-stage shape rather than a single promise.
97
106
  *
98
107
  * @param {string} expectedState
99
- * @returns {Promise<{ redirectUri: string, code: Promise<string>, close: () => void }>}
108
+ * @typedef {{ code: string, respond: (status: number, page: string) => void }} Callback
109
+ * @returns {Promise<{ redirectUri: string, code: Promise<Callback>, close: () => void }>}
100
110
  */
101
111
  function startCallbackServer(expectedState) {
102
112
  return new Promise((resolveReady, rejectReady) => {
@@ -115,7 +125,7 @@ function startCallbackServer(expectedState) {
115
125
 
116
126
  const fail = (message) => {
117
127
  res.writeHead(400, { 'Content-Type': 'text/html' });
118
- res.end(DONE_PAGE('Sign-in failed', message));
128
+ res.end(DONE_PAGE('Sign-in failed', `<p>${escapeHtml(message)}</p>`));
119
129
  if (!finished) {
120
130
  finished = true;
121
131
  settle.reject(new Error(message));
@@ -144,11 +154,20 @@ function startCallbackServer(expectedState) {
144
154
  return;
145
155
  }
146
156
 
147
- res.writeHead(200, { 'Content-Type': 'text/html' });
148
- res.end(DONE_PAGE('You are signed in', 'You can close this tab and return to your editor.'));
157
+ // The page is NOT written here. It waits for the token exchange, so it
158
+ // can say which account was signed in (#2654). With an existing Keycloak
159
+ // session the browser never shows a login or consent screen — the tab
160
+ // flashes straight here — and "you are signed in" with no name is how
161
+ // someone ends up connected as an identity they did not expect.
149
162
  if (!finished) {
150
163
  finished = true;
151
- settle.resolve(received);
164
+ settle.resolve({ code: received, respond: (status, page) => {
165
+ res.writeHead(status, { 'Content-Type': 'text/html' });
166
+ res.end(page);
167
+ } });
168
+ } else {
169
+ res.writeHead(200, { 'Content-Type': 'text/html' });
170
+ res.end(DONE_PAGE('Already handled', '<p>This sign-in has already completed. You can close this tab.</p>'));
152
171
  }
153
172
  });
154
173
 
@@ -254,17 +273,32 @@ export async function beginLogin() {
254
273
  getLogger().debug('OAuth sign-in started', { redirectUri, environment: endpoints.environment });
255
274
 
256
275
  const complete = async () => {
257
- const authorizationCode = await code;
258
- const response = await postToken({
259
- grant_type: 'authorization_code',
260
- client_id: endpoints.clientId,
261
- code: authorizationCode,
262
- redirect_uri: redirectUri,
263
- code_verifier: verifier,
264
- });
265
- const tokens = toStoredTokens(response);
266
- writeTokens(tokens);
276
+ const { code: authorizationCode, respond } = await code;
277
+ let tokens;
278
+ try {
279
+ const response = await postToken({
280
+ grant_type: 'authorization_code',
281
+ client_id: endpoints.clientId,
282
+ code: authorizationCode,
283
+ redirect_uri: redirectUri,
284
+ code_verifier: verifier,
285
+ });
286
+ tokens = toStoredTokens(response);
287
+ writeTokens(tokens);
288
+ } catch (error) {
289
+ // The browser is still waiting on this response. Leaving it hanging
290
+ // would look exactly like the silent success this page exists to avoid.
291
+ respond(400, DONE_PAGE('Sign-in failed',
292
+ `<p>${escapeHtml(error.message)}</p><p>Return to your editor and try again.</p>`));
293
+ throw error;
294
+ }
295
+
267
296
  getLogger().info('OAuth sign-in complete', { email: tokens.email });
297
+ respond(200, DONE_PAGE('Signed in to EzModo',
298
+ (tokens.email ? `<p>as <strong>${escapeHtml(tokens.email)}</strong></p>` : '') +
299
+ '<p>You can close this tab and return to your editor.</p>' +
300
+ '<p>Not the account you meant? Ask your agent to run <code>authenticate</code> ' +
301
+ 'with <code>sign_out</code>, then sign in again.</p>'));
268
302
  return tokens;
269
303
  };
270
304
 
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  *
8
8
  * Update this when bumping the version in package.json.
9
9
  */
10
- export const MCP_VERSION = '0.14.1';
10
+ export const MCP_VERSION = '0.14.3';
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "MCP server for ezmodo - AI-first project management",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
+ "mcp-server": "./index.js",
8
9
  "ezmodo-mcp-server": "./index.js",
9
10
  "zephly-mcp-server": "./index.js",
10
11
  "ezmodo-mcp-server-http": "./http.js"
@@ -49,6 +50,11 @@
49
50
  "tools/"
50
51
  ],
51
52
  "homepage": "https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/EasyModeOnly/ezmodo-mcp-server.git",
56
+ "directory": "mcp-server"
57
+ },
52
58
  "bugs": {
53
59
  "url": "https://ezmodo.com/support",
54
60
  "email": "help@ezmodo.com"
package/tools/auth.js CHANGED
@@ -11,11 +11,13 @@ export const AUTH_TOOLS = [
11
11
  {
12
12
  name: 'authenticate',
13
13
  description:
14
- 'Sign this EzModo MCP server in, or report who it is signed in as. Call ' +
15
- 'it when a tool reports that no credential is available. `login` ' +
16
- 'returns a URL to open in a browser — SHOW THAT URL TO THE USER, then ' +
17
- 'retry the original call once they say they have approved it. Not ' +
18
- 'needed when EZMODO_API_KEY is set.',
14
+ 'Sign this EzModo MCP server in, or report who it is signed in as. You ' +
15
+ 'rarely need `login`: any tool called with no credential starts the ' +
16
+ 'browser sign-in itself and returns its `authUrl` — SHOW THAT URL TO THE ' +
17
+ 'USER, then retry the original call once they have approved it. Use ' +
18
+ '`status` to see which account calls run as (useful when the browser ' +
19
+ 'signed in silently from an existing session), and `sign_out` to switch ' +
20
+ 'accounts. Not needed when EZMODO_API_KEY is set.',
19
21
  inputSchema: {
20
22
  type: 'object',
21
23
  properties: {