@ezmodo/mcp-server 0.14.0 → 0.14.1

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
@@ -2,73 +2,114 @@
2
2
 
3
3
  Model Context Protocol (MCP) server for ezmodo - AI-first project management.
4
4
 
5
- ## Installation
5
+ ## Install
6
6
 
7
- ### Global Installation (Recommended)
7
+ Point any MCP client at the package. There is nothing to configure:
8
8
 
9
- ```bash
10
- npm install -g @ezmodo/mcp-server
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "ezmodo": {
13
+ "command": "npx",
14
+ "args": ["-y", "@ezmodo/mcp-server"]
15
+ }
16
+ }
17
+ }
11
18
  ```
12
19
 
13
- ### Local Installation
20
+ The first tool call returns a sign-in URL. Open it, approve the access, and
21
+ retry — the server holds the token from then on and refreshes it itself. No API
22
+ key, no environment variable, no CLI.
14
23
 
15
- For local development, use the ezmodo CLI (recommended):
16
-
17
- ```bash
18
- # Install CLI
19
- curl -fsSL https://ezmodo.com/cli/install/scripts/latest/install.sh | sh
24
+ That block is the same for Claude Desktop, Cursor, Windsurf, Zed, Codex and
25
+ anything else that speaks stdio MCP; only the file it goes in differs.
20
26
 
21
- # Authenticate
22
- ezmodo auth login
27
+ **Claude Code users should install the plugin instead**, which bundles this
28
+ server along with the work-tracking skills, slash commands and hooks:
23
29
 
24
- # Configure MCP
25
- ezmodo mcp install
26
30
  ```
31
+ /plugin marketplace add EasyModeOnly/ezmodo-plugins
32
+ /plugin install ezmodo@ezmodo
33
+ ```
34
+
35
+ ### Signing in
27
36
 
28
- ## Prerequisites
37
+ Sign-in happens through the `authenticate` tool rather than at startup, because
38
+ a server launched by an editor has no terminal to prompt in:
29
39
 
30
- 1. **ezmodo Account**: Sign up at [ezmodo.com](https://ezmodo.com)
31
- 2. **API Key**: Generate an API key in your ezmodo account settings
40
+ | Action | What it does |
41
+ | --- | --- |
42
+ | `authenticate` | Starts browser sign-in and returns the URL to open |
43
+ | `authenticate action:"status"` | Reports which credential is in use |
44
+ | `authenticate action:"sign_out"` | Forgets the stored tokens |
32
45
 
33
- ### Generating an API Key
46
+ It returns the URL immediately rather than blocking until you finish in the
47
+ browser, so it cannot trip a client's tool-call timeout. Approve, then retry
48
+ whatever you were doing.
34
49
 
35
- 1. Log in to [ezmodo.com](https://ezmodo.com)
36
- 2. Navigate to **Settings** → **API Keys**
37
- 3. Click **Generate New API Key**
38
- 4. Copy your API key
39
- 5. Store it securely - you won't be able to see it again!
50
+ Tokens are stored at `~/.config/ezmodo/mcp-oauth.json` (`%APPDATA%\ezmodo\` on
51
+ Windows), mode `0600`.
40
52
 
41
- ## Configuration
53
+ The sign-in asks for `ezmodo:read` and `ezmodo:write`. It deliberately does not
54
+ ask for `ezmodo:delete`: consent is accept-or-decline over the whole set, and
55
+ "permanently delete your projects" should not be a condition of installing an
56
+ MCP server. Set `EZMODO_OAUTH_SCOPES` if you want a different set.
42
57
 
43
- ### For Claude Desktop
58
+ ## Using an API key instead
44
59
 
45
- Add to your `claude_desktop_config.json`:
60
+ For CI, containers and anything headless where no browser exists, set
61
+ `EZMODO_API_KEY` and skip sign-in entirely:
46
62
 
47
63
  ```json
48
64
  {
49
65
  "mcpServers": {
50
66
  "ezmodo": {
51
- "command": "ezmodo-mcp-server",
67
+ "command": "npx",
68
+ "args": ["-y", "@ezmodo/mcp-server"],
52
69
  "env": {
53
- "EZMODO_API_KEY": "your_api_key_here",
54
- "EZMODO_API_URL": "https://ezmodo.com/api"
70
+ "EZMODO_API_KEY": "ezm_sk_your_key_here"
55
71
  }
56
72
  }
57
73
  }
58
74
  }
59
75
  ```
60
76
 
61
- **Configuration Options:**
77
+ Generate one at **Settings → API Keys** on [ezmodo.com](https://ezmodo.com).
78
+ Keys start with `ezm_sk_` and are shown once. Pre-rename `zeph_sk_` keys remain
79
+ valid indefinitely.
80
+
81
+ ### Which credential wins
82
+
83
+ Resolved per call, in this order:
84
+
85
+ 1. `EZMODO_API_KEY` (or legacy `ZEPHLY_API_KEY`) — an explicit credential beats
86
+ an implicit one, which is what keeps CI predictable.
87
+ 2. The OAuth token this server obtained for itself.
88
+ 3. The credential `ezmodo auth login` stored, if you happen to have the CLI.
89
+
90
+ `authenticate action:"status"` reports which of these is actually in use.
91
+
92
+ ### Other environment variables
93
+
94
+ - `EZMODO_API_URL` (optional): custom API URL. Defaults to production
95
+ (`https://ezmodo.com/api`).
96
+ - `EZMODO_OAUTH_SCOPES` (optional): space-separated scopes to request at
97
+ sign-in.
98
+
99
+ ## What the server provides besides tools
62
100
 
63
- - `EZMODO_API_KEY` (required): Your ezmodo API key. Legacy `ZEPHLY_API_KEY` is still read as a fallback during the rebrand transition.
64
- - `EZMODO_API_URL` (optional): Custom API URL. Defaults to production (`https://ezmodo.com/api`).
101
+ **Instructions.** The initialize result carries the work-tracking contract
102
+ create the task before you edit, tick steps off, capture knowledge, link the
103
+ commit, finish at `in_review`. Most clients inject it into system context, so
104
+ the discipline travels with the tools rather than needing a per-editor plugin.
65
105
 
66
- ### For Local Development
106
+ **Prompts.** `start`, `resume`, `submit` and `untracked`, which clients surface
107
+ as slash commands. `submit` is local-only, since it reads git SHAs.
67
108
 
68
- If running from source:
109
+ ## Running from source
69
110
 
70
111
  ```bash
71
- export EZMODO_API_KEY="your_api_key_here"
112
+ export EZMODO_API_KEY="ezm_sk_your_key_here"
72
113
  export EZMODO_API_URL="http://localhost:8787/api" # Local Go API
73
114
  npm run dev
74
115
  ```
@@ -248,21 +289,50 @@ When working with the MCP tools:
248
289
 
249
290
  ## Troubleshooting
250
291
 
251
- ### "Missing Authorization header"
252
- - Ensure `EZMODO_API_KEY` is set in your configuration
292
+ ### "Not authenticated with EzModo"
293
+ Nobody has signed in and no `EZMODO_API_KEY` is set. Call `authenticate`, open
294
+ the URL it returns, then retry. Tool calls return this as guidance rather than a
295
+ bare error, so the agent can act on it.
296
+
297
+ ### A 403 after signing in successfully
298
+ Signing in worked; the account just does not belong to any EzModo organization
299
+ yet, so there is nothing for tools to return. Signing in again will not change
300
+ it — no credential substitutes for belonging to a workspace.
301
+
302
+ Tool calls answer this one themselves: the result carries an `onboardingUrl`,
303
+ and opening it lets you create a personal workspace, create a team, or ask to
304
+ join an organization your verified email already matches. If you expected to be
305
+ a member of one already, you are probably signed in as a different identity than
306
+ you think — check which email that page shows, and ask an administrator to
307
+ invite that exact address.
308
+
309
+ ### "This email address already belongs to a different EzModo account"
310
+ Sign-in worked, but no account could be created for the identity you signed in
311
+ with, because that address is already registered under a different one — most
312
+ often a password account and a Google/Microsoft/GitHub/Apple sign-in for the
313
+ same person. Creating a workspace will not help; the same collision happens
314
+ there. Sign in the way you originally did, or contact support to have the two
315
+ linked.
316
+
317
+ ### Sign-in never completes
318
+ The flow needs a browser — this client cannot take a username and password
319
+ directly, because skipping the browser would skip the consent screen. If no
320
+ browser can open (over SSH, or in a container), the URL is still in the tool
321
+ result; open it anywhere and approve. Or set `EZMODO_API_KEY` instead, which is
322
+ the right answer for a headless machine.
253
323
 
254
324
  ### "Invalid or revoked API key"
255
- - Regenerate your API key in ezmodo settings
256
- - Ensure the key hasn't expired or been revoked
325
+ Regenerate the key in ezmodo settings. Check it has not been revoked, and that
326
+ it has not passed an expiry date if you set one.
257
327
 
258
328
  ### "Failed to connect to API"
259
329
  - Check your `EZMODO_API_URL` configuration
260
330
  - Verify your internet connection
261
331
 
262
- ### Claude Desktop doesn't see the server
263
- - Restart Claude Desktop after updating config
332
+ ### The client doesn't see the server
333
+ - Restart it after updating config — MCP servers are read at startup
264
334
  - Check the config file path is correct
265
- - Verify JSON syntax in `claude_desktop_config.json`
335
+ - Verify the JSON syntax
266
336
 
267
337
  ## Development
268
338
 
@@ -21,6 +21,24 @@ import { CONFIG } from '../config/index.js';
21
21
  /** Marks the "there is no credential at all" failure, so dispatch can spot it. */
22
22
  export const NOT_AUTHENTICATED = 'EZMODO_NOT_AUTHENTICATED';
23
23
 
24
+ /**
25
+ * The API's code for "signed in, but in no organization" (#2639).
26
+ *
27
+ * Must match middleware.CodeNoOrganization in
28
+ * api/internal/api/middleware/oidc_mcp_auth.go. A code rather than a message
29
+ * match, because the message is prose someone will improve.
30
+ */
31
+ export const NO_ORGANIZATION = 'NO_ORGANIZATION';
32
+
33
+ /**
34
+ * The API's code for "this email already belongs to another identity" (#2652).
35
+ *
36
+ * Must match middleware.CodeEmailAlreadyRegistered. Kept separate from
37
+ * NO_ORGANIZATION because the answers point opposite ways: that one sends you
38
+ * to onboarding, and onboarding is exactly where this one fails again.
39
+ */
40
+ export const EMAIL_ALREADY_REGISTERED = 'EMAIL_ALREADY_REGISTERED';
41
+
24
42
  /**
25
43
  * The two things a user cannot guess, and will otherwise hit as bare failures.
26
44
  *
@@ -34,8 +52,9 @@ const CAVEATS = [
34
52
  'skipping the browser also skips the consent screen, which is the whole ' +
35
53
  'reason for using OAuth here rather than a pasted key.',
36
54
  'A brand-new EzModo account belongs to no organization yet, so tools will ' +
37
- 'return 403 until someone adds you to one. That is an access problem, not ' +
38
- 'a sign-in problem — signing in again will not change it.',
55
+ 'return 403 until it does. That is an access problem, not a sign-in ' +
56
+ 'problem — signing in again will not change it. The call that hits it ' +
57
+ 'says how to fix it; see `organizationRequired` below.',
39
58
  ];
40
59
 
41
60
  /**
@@ -65,3 +84,92 @@ export function signInRequired({ authUrl, reason } = {}) {
65
84
  notes: CAVEATS,
66
85
  };
67
86
  }
87
+
88
+ /**
89
+ * The payload returned when the caller is signed in but belongs to no
90
+ * organization (#2639).
91
+ *
92
+ * Why this is a separate answer from `signInRequired`, and not a widening of
93
+ * it: sign-in already worked. Sending this person back through a browser flow
94
+ * produces the identical token and the identical failure, which is precisely
95
+ * why #2632 kept 403 out of that funnel. What they need is the OTHER half of
96
+ * onboarding — a workspace — and EzModo already has a surface for it.
97
+ *
98
+ * SO THIS DOES NOT INVENT A MECHANISM. The web app routes a signed-in user
99
+ * with no organizations to /onboarding, which offers a personal workspace, a
100
+ * new team, or /join — workspace discovery, which lists organizations matching
101
+ * a verified email domain and joins none of them without a click. Matching an
102
+ * organization here instead would be a second, quieter copy of that consent
103
+ * decision, and the quiet copy is the one that gets it wrong.
104
+ *
105
+ * Returned on BOTH surfaces, unlike the sign-in prompt. Over the connector
106
+ * Claude owns the OAuth, so sign-in advice from here would be wrong — but the
107
+ * missing workspace is ours either way, and a claude.ai user hits it exactly
108
+ * as a local one does.
109
+ *
110
+ * @param {object} options
111
+ * @param {string} [options.reason] What the API said, in one line.
112
+ */
113
+ export function organizationRequired({ reason } = {}) {
114
+ return {
115
+ authenticated: true,
116
+ organization: null,
117
+ reason:
118
+ reason ||
119
+ 'This account is signed in but does not belong to any EzModo organization.',
120
+ action_required:
121
+ 'Open the URL below and finish setting up a workspace, then retry the call. ' +
122
+ 'You can create a personal workspace, create a team, or ask to join an ' +
123
+ 'organization your verified email already matches.',
124
+ onboardingUrl: `${CONFIG.webUrl}/onboarding`,
125
+ notes: [
126
+ 'Sign-in itself succeeded. Signing in again will not change this, and ' +
127
+ 'no credential — OAuth token or API key — can substitute for belonging ' +
128
+ 'to an organization.',
129
+ 'If you expected to already be a member of one, you are probably signed ' +
130
+ `in as a different identity than you think. Check which email ${CONFIG.webUrl}/onboarding ` +
131
+ 'shows, and ask an administrator to invite that exact address.',
132
+ ],
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The payload returned when sign-in succeeded but no account could be created
138
+ * for it, because the email already belongs to a different EzModo identity
139
+ * (#2652).
140
+ *
141
+ * The reason this is not folded into `organizationRequired`: both used to look
142
+ * identical from outside — a signed-in caller getting nothing back — and the
143
+ * fix for one is a dead end for the other. Creating a workspace runs the same
144
+ * insert against the same unique index and fails the same way. Telling someone
145
+ * to go do that is worse than telling them nothing, because they will believe
146
+ * it and try.
147
+ *
148
+ * There is deliberately no self-service action here. Merging two identities
149
+ * onto one account is exactly the operation that must not be automated from an
150
+ * unauthenticated-by-the-other-party direction, so the honest answer is the
151
+ * original sign-in method, or a human.
152
+ *
153
+ * @param {object} options
154
+ * @param {string} [options.reason] What the API said, in one line.
155
+ */
156
+ export function emailAlreadyRegistered({ reason } = {}) {
157
+ return {
158
+ authenticated: true,
159
+ accountProvisioned: false,
160
+ reason:
161
+ reason ||
162
+ 'This email address already belongs to a different EzModo account, so no ' +
163
+ 'account could be created for the identity you signed in with.',
164
+ action_required:
165
+ 'Sign in the way you originally did — a password, or whichever of Google, ' +
166
+ 'Microsoft, GitHub or Apple you used first. If you need the two linked, ' +
167
+ 'contact support.',
168
+ notes: [
169
+ 'Creating a workspace will NOT fix this, and neither will signing in ' +
170
+ 'again through the same provider. Nothing is wrong with the sign-in; ' +
171
+ 'the account simply could not be created.',
172
+ `You can check which address is in use at ${CONFIG.webUrl}/settings.`,
173
+ ],
174
+ };
175
+ }
@@ -26,7 +26,14 @@ import { listPrompts, getPromptContent } from '../prompts/index.js';
26
26
  import { MCP_VERSION } from './version.js';
27
27
  import { getLogger } from './logger.js';
28
28
  import { isRemoteSafe } from './remote-tools.js';
29
- import { NOT_AUTHENTICATED, signInRequired } from './auth-guidance.js';
29
+ import {
30
+ EMAIL_ALREADY_REGISTERED,
31
+ NOT_AUTHENTICATED,
32
+ NO_ORGANIZATION,
33
+ emailAlreadyRegistered,
34
+ organizationRequired,
35
+ signInRequired,
36
+ } from './auth-guidance.js';
30
37
  import { getInstructions } from './instructions.js';
31
38
 
32
39
  /**
@@ -47,12 +54,33 @@ import { getInstructions } from './instructions.js';
47
54
  *
48
55
  * 403 deliberately does NOT count. That means signed in but not permitted,
49
56
  * most often a new account in no organization yet, and sending someone back
50
- * through a sign-in that cannot fix it is worse than saying nothing.
57
+ * through a sign-in that cannot fix it is worse than saying nothing. That case
58
+ * gets its own answer below — saying nothing was the placeholder, not the plan.
51
59
  */
52
60
  function isAuthFailure(error) {
53
61
  return error?.code === NOT_AUTHENTICATED || error?.status === 401;
54
62
  }
55
63
 
64
+ /**
65
+ * Whether a failure means "signed in, but in no organization" (#2639).
66
+ *
67
+ * Keyed on the API's code, never on the message: the API owns the wording and
68
+ * will improve it, and a prose match that silently stops matching degrades to
69
+ * the bare 403 this exists to replace — the failure would be invisible, since
70
+ * the call still fails either way, just uselessly.
71
+ */
72
+ function isNoOrganization(error) {
73
+ return error?.code === NO_ORGANIZATION;
74
+ }
75
+
76
+ /**
77
+ * Whether a failure means "signed in, but this email is already spoken for"
78
+ * (#2652). Same keying, and the same reason for it.
79
+ */
80
+ function isEmailAlreadyRegistered(error) {
81
+ return error?.code === EMAIL_ALREADY_REGISTERED;
82
+ }
83
+
56
84
  export function createServer({ surface = 'local' } = {}) {
57
85
  const log = getLogger();
58
86
 
@@ -122,6 +150,28 @@ export function createServer({ surface = 'local' } = {}) {
122
150
  isError: true,
123
151
  };
124
152
  }
153
+ // Answered on EVERY surface, unlike the sign-in prompt above. Sign-in
154
+ // advice is surface-specific because over the connector Claude owns the
155
+ // OAuth; a missing workspace is ours either way, and a claude.ai user
156
+ // hits it exactly as a local one does.
157
+ if (isNoOrganization(error)) {
158
+ return {
159
+ content: [
160
+ { type: 'text', text: JSON.stringify(organizationRequired({ reason: errMsg }), null, 2) },
161
+ ],
162
+ isError: true,
163
+ };
164
+ }
165
+ // Checked separately from the branch above, never merged into it: these
166
+ // two look alike from outside and their answers point opposite ways.
167
+ if (isEmailAlreadyRegistered(error)) {
168
+ return {
169
+ content: [
170
+ { type: 'text', text: JSON.stringify(emailAlreadyRegistered({ reason: errMsg }), null, 2) },
171
+ ],
172
+ isError: true,
173
+ };
174
+ }
125
175
  // Returned as content rather than thrown: a tool that fails is a result
126
176
  // the model can read and act on, not a transport error.
127
177
  return {
@@ -163,6 +163,12 @@ export async function callZephlyAPI(endpoint, data) {
163
163
  // status and retryable let callers back off on a 503 instead of giving up
164
164
  // the way a 404 tells them to.
165
165
  thrown.status = response.status;
166
+ // A machine-readable code, when the API sent one. Carried so a caller can
167
+ // branch on the KIND of failure rather than on its prose — the no-organization
168
+ // 403 (#2639) is answered with specific guidance, and every other 403 is not.
169
+ if (typeof error.code === 'string' && error.code) {
170
+ thrown.code = error.code;
171
+ }
166
172
  if (typeof error.retryable === 'boolean') {
167
173
  thrown.retryable = error.retryable;
168
174
  }
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.0';
10
+ export const MCP_VERSION = '0.14.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "MCP server for ezmodo - AI-first project management",
5
5
  "main": "index.js",
6
6
  "type": "module",