@ezmodo/mcp-server 0.13.5 → 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 +112 -42
- package/handlers/auth.js +160 -0
- package/handlers/index.js +2 -0
- package/http.js +6 -1
- package/index.js +21 -52
- package/lib/auth-guidance.js +175 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +97 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +25 -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 +6 -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/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
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Point any MCP client at the package. There is nothing to configure:
|
|
8
8
|
|
|
9
|
-
```
|
|
10
|
-
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"ezmodo": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@ezmodo/mcp-server"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
## Using an API key instead
|
|
44
59
|
|
|
45
|
-
|
|
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": "
|
|
67
|
+
"command": "npx",
|
|
68
|
+
"args": ["-y", "@ezmodo/mcp-server"],
|
|
52
69
|
"env": {
|
|
53
|
-
"EZMODO_API_KEY": "
|
|
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
|
-
**
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
## Running from source
|
|
69
110
|
|
|
70
111
|
```bash
|
|
71
|
-
export EZMODO_API_KEY="
|
|
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
|
-
### "
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
###
|
|
263
|
-
- Restart
|
|
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
|
|
335
|
+
- Verify the JSON syntax
|
|
266
336
|
|
|
267
337
|
## Development
|
|
268
338
|
|
package/handlers/auth.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in handlers (#2632).
|
|
3
|
+
*
|
|
4
|
+
* THE SHAPE THAT MATTERS: `login` returns as soon as it has a URL, and does
|
|
5
|
+
* NOT wait for the user.
|
|
6
|
+
*
|
|
7
|
+
* Blocking until the browser round trip finished would read better in a
|
|
8
|
+
* transcript — one call, "signed in as you" — but it puts a human's attention
|
|
9
|
+
* span on the critical path of a tool call. Clients time tool calls out at
|
|
10
|
+
* wildly different limits, and a client that gives up at thirty seconds would
|
|
11
|
+
* report a failure for a sign-in that then succeeds in the background, leaving
|
|
12
|
+
* the agent with a wrong answer and the user with a working login. Returning
|
|
13
|
+
* immediately is correct under every timeout.
|
|
14
|
+
*
|
|
15
|
+
* The flow keeps running after the return: the loopback listener is alive in
|
|
16
|
+
* this process, and completion writes the tokens itself. So the recovery is
|
|
17
|
+
* simply for the agent to retry whatever it was doing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { beginLogin, getSignedInIdentity, signOut } from '../lib/oauth.js';
|
|
21
|
+
import { getApiKey } from '../lib/env.js';
|
|
22
|
+
import { signInRequired } from '../lib/auth-guidance.js';
|
|
23
|
+
import { resetCredentialCache } from '../lib/credentials.js';
|
|
24
|
+
import { getLogger } from '../lib/logger.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The sign-in currently waiting on a browser, if any.
|
|
28
|
+
*
|
|
29
|
+
* Held so a second `login` while one is already open returns the SAME URL
|
|
30
|
+
* rather than starting a rival flow. Two live flows would mean two loopback
|
|
31
|
+
* listeners and two states, and whichever URL the user did not click would sit
|
|
32
|
+
* there until it timed out.
|
|
33
|
+
*/
|
|
34
|
+
let pending = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Abandon a sign-in that is still waiting on a browser.
|
|
38
|
+
*
|
|
39
|
+
* Real behaviour, not just a test seam: signing out while a browser tab is
|
|
40
|
+
* still open should not leave a loopback listener alive that would quietly
|
|
41
|
+
* complete the login the user just cancelled.
|
|
42
|
+
*/
|
|
43
|
+
export function cancelPendingLogin() {
|
|
44
|
+
if (!pending) return false;
|
|
45
|
+
pending.cancel();
|
|
46
|
+
pending = null;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Open a URL in the user's browser, best-effort. */
|
|
51
|
+
async function openBrowser(url) {
|
|
52
|
+
const { execFile } = await import('child_process');
|
|
53
|
+
const commands =
|
|
54
|
+
process.platform === 'darwin'
|
|
55
|
+
? [['open', [url]]]
|
|
56
|
+
: process.platform === 'win32'
|
|
57
|
+
? [['cmd', ['/c', 'start', '', url]]]
|
|
58
|
+
: [
|
|
59
|
+
['xdg-open', [url]],
|
|
60
|
+
['sensible-browser', [url]],
|
|
61
|
+
['x-www-browser', [url]],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
for (const [command, args] of commands) {
|
|
65
|
+
const opened = await new Promise((resolve) => {
|
|
66
|
+
// execFile, not exec: no shell, so a URL cannot be read as shell syntax.
|
|
67
|
+
execFile(command, args, (error) => resolve(!error));
|
|
68
|
+
});
|
|
69
|
+
if (opened) return true;
|
|
70
|
+
}
|
|
71
|
+
// Not a failure. The URL in the result is the contract; the browser launch
|
|
72
|
+
// is a convenience, and it is expected to fail over SSH or in a container.
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function login() {
|
|
77
|
+
const log = getLogger();
|
|
78
|
+
|
|
79
|
+
if (pending) {
|
|
80
|
+
return {
|
|
81
|
+
...signInRequired({ authUrl: pending.authUrl, reason: 'A sign-in is already waiting.' }),
|
|
82
|
+
browserOpened: pending.browserOpened,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const flow = await beginLogin();
|
|
87
|
+
const browserOpened = await openBrowser(flow.authUrl);
|
|
88
|
+
|
|
89
|
+
pending = { authUrl: flow.authUrl, browserOpened, cancel: flow.cancel };
|
|
90
|
+
flow
|
|
91
|
+
.complete()
|
|
92
|
+
.then((tokens) => {
|
|
93
|
+
// The credential chain memoizes the CLI lookup; drop it so the fresh
|
|
94
|
+
// OAuth token is what the next call sees.
|
|
95
|
+
resetCredentialCache();
|
|
96
|
+
log.info('Browser sign-in completed', { email: tokens.email });
|
|
97
|
+
})
|
|
98
|
+
.catch((error) => log.warn('Browser sign-in did not complete', { error: error.message }))
|
|
99
|
+
.finally(() => {
|
|
100
|
+
pending = null;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...signInRequired({
|
|
106
|
+
authUrl: flow.authUrl,
|
|
107
|
+
reason: 'Sign-in started. Waiting for you to approve it in a browser.',
|
|
108
|
+
}),
|
|
109
|
+
browserOpened,
|
|
110
|
+
next_step: browserOpened
|
|
111
|
+
? 'A browser should have opened. Once approved, retry the call you were making.'
|
|
112
|
+
: 'No browser could be opened here — show the URL to the user, then retry the call.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function status() {
|
|
117
|
+
const envKey = getApiKey();
|
|
118
|
+
if (envKey) {
|
|
119
|
+
return {
|
|
120
|
+
authenticated: true,
|
|
121
|
+
source: 'EZMODO_API_KEY',
|
|
122
|
+
note: 'An explicit key is set, so it takes precedence over any browser sign-in.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const identity = getSignedInIdentity();
|
|
127
|
+
if (identity) {
|
|
128
|
+
return { authenticated: true, source: 'OAuth', ...identity };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return signInRequired({ reason: 'This server is not signed in.' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {{ action?: 'login'|'status'|'sign_out' }} args
|
|
136
|
+
*/
|
|
137
|
+
export async function authenticate(args = {}) {
|
|
138
|
+
switch (args.action || 'login') {
|
|
139
|
+
case 'status':
|
|
140
|
+
return status();
|
|
141
|
+
case 'sign_out': {
|
|
142
|
+
// Cancel first: a listener left alive would complete the very sign-in
|
|
143
|
+
// being abandoned.
|
|
144
|
+
const cancelled = cancelPendingLogin();
|
|
145
|
+
signOut();
|
|
146
|
+
resetCredentialCache();
|
|
147
|
+
return {
|
|
148
|
+
signedOut: true,
|
|
149
|
+
cancelledPendingSignIn: cancelled,
|
|
150
|
+
note:
|
|
151
|
+
'Stored OAuth tokens removed. EZMODO_API_KEY and the ezmodo CLI login ' +
|
|
152
|
+
'are untouched — this server does not own either.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
case 'login':
|
|
156
|
+
return login();
|
|
157
|
+
default:
|
|
158
|
+
throw new Error(`Unknown action: ${args.action}. Use login, status or sign_out.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
package/handlers/index.js
CHANGED
|
@@ -38,12 +38,14 @@ import * as agentHandlers from './agents.js';
|
|
|
38
38
|
import * as recurringTaskHandlers from './recurring-tasks.js';
|
|
39
39
|
import * as workTemplateHandlers from './work-templates.js';
|
|
40
40
|
import { manageWorktree, listProjectWorktrees } from '../lib/worktree-tools.js';
|
|
41
|
+
import { authenticate } from './auth.js';
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Map of tool names to handler functions
|
|
44
45
|
* Used by the MCP server to route tool calls
|
|
45
46
|
*/
|
|
46
47
|
export const HANDLERS = {
|
|
48
|
+
authenticate,
|
|
47
49
|
// Organizations
|
|
48
50
|
get_organization: organizationHandlers.getOrganization,
|
|
49
51
|
|
package/http.js
CHANGED
|
@@ -91,7 +91,12 @@ export function protectedResourceMetadata() {
|
|
|
91
91
|
authorization_servers: AUTH_SERVER ? [AUTH_SERVER] : [],
|
|
92
92
|
scopes_supported: CONSENT_SCOPES,
|
|
93
93
|
bearer_methods_supported: ['header'],
|
|
94
|
-
|
|
94
|
+
// The DB-backed help centre, verified to resolve. /help/connectors was
|
|
95
|
+
// invented for this field and never existed — a dead link shipped inside a
|
|
96
|
+
// public discovery document, where nobody would notice because nothing in
|
|
97
|
+
// the handshake reads it.
|
|
98
|
+
resource_documentation:
|
|
99
|
+
process.env.MCP_DOCS_URL || 'https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp',
|
|
95
100
|
};
|
|
96
101
|
}
|
|
97
102
|
|
package/index.js
CHANGED
|
@@ -15,8 +15,7 @@ import { CONFIG } from './config/index.js';
|
|
|
15
15
|
// The tool surface, shared with the HTTP entry point (http.js).
|
|
16
16
|
import { createServer } from './lib/create-server.js';
|
|
17
17
|
import { initLogger, getLogger } from './lib/logger.js';
|
|
18
|
-
import {
|
|
19
|
-
import { readCliCredential } from './lib/cli-credential.js';
|
|
18
|
+
import { describeCredentialSync } from './lib/credentials.js';
|
|
20
19
|
|
|
21
20
|
// ============================================================
|
|
22
21
|
// Configuration Validation
|
|
@@ -26,66 +25,36 @@ import { readCliCredential } from './lib/cli-credential.js';
|
|
|
26
25
|
initLogger();
|
|
27
26
|
const log = getLogger();
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
let apiKeySource = 'EZMODO_API_KEY';
|
|
31
|
-
|
|
32
|
-
// Fall back to the credential `ezmodo auth login` already stored (#2611).
|
|
28
|
+
// The credential is REPORTED here, not required here.
|
|
33
29
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
30
|
+
// This used to exit(1) when it found no API key, which was right when a key
|
|
31
|
+
// was the only way in: a server that cannot authenticate can do nothing, so
|
|
32
|
+
// failing loudly beat failing on every tool call. OAuth changes that (#2631) —
|
|
33
|
+
// signing in happens through the browser AFTER startup, so a server with no
|
|
34
|
+
// credential yet is not broken, it is waiting. Exiting would make the one case
|
|
35
|
+
// we now want to support impossible.
|
|
37
36
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
if (!apiKey) {
|
|
43
|
-
const stored = readCliCredential();
|
|
44
|
-
if (stored) {
|
|
45
|
-
process.env.EZMODO_API_KEY = stored.key;
|
|
46
|
-
apiKey = stored.key;
|
|
47
|
-
apiKeySource = stored.source;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!apiKey) {
|
|
52
|
-
log.error('No API key: EZMODO_API_KEY is unset and no ezmodo CLI credential was found', {
|
|
53
|
-
settingsUrl: CONFIG.settingsUrl,
|
|
54
|
-
});
|
|
55
|
-
console.error('ERROR: No ezmodo API key found.');
|
|
56
|
-
console.error('');
|
|
57
|
-
console.error('Checked, in order:');
|
|
58
|
-
console.error(' 1. EZMODO_API_KEY (or legacy ZEPHLY_API_KEY) — not set');
|
|
59
|
-
console.error(' 2. the credential stored by `ezmodo auth login` — not found');
|
|
60
|
-
console.error('');
|
|
61
|
-
console.error('Fix it either way:');
|
|
62
|
-
console.error(' • run `ezmodo auth login`, or');
|
|
63
|
-
console.error(' • set EZMODO_API_KEY in the environment');
|
|
64
|
-
console.error('');
|
|
65
|
-
console.error(`Generate a key at: ${CONFIG.settingsUrl}`);
|
|
66
|
-
console.error('\nOptional environment variables:');
|
|
67
|
-
console.error('- EZMODO_API_URL (default: production Go API URL)');
|
|
68
|
-
console.error('- EZMODO_ENVIRONMENT (dev/staging/production)');
|
|
69
|
-
console.error('\nEnvironment-specific URLs:');
|
|
70
|
-
console.error(' - dev: https://dev.ezmodo.com/api');
|
|
71
|
-
console.error(' - staging: https://staging.ezmodo.com/api');
|
|
72
|
-
console.error(' - production: https://ezmodo.com/api');
|
|
73
|
-
console.error(' - local: http://localhost:8787/api');
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
37
|
+
// Nothing is resolved asynchronously here either: a stale token would make the
|
|
38
|
+
// server block on Keycloak before it ever spoke MCP. The refresh happens on
|
|
39
|
+
// the first call that needs one.
|
|
40
|
+
const credential = describeCredentialSync();
|
|
76
41
|
|
|
77
42
|
log.info('MCP server starting', {
|
|
78
43
|
environment: CONFIG.environment,
|
|
79
44
|
apiUrl: CONFIG.apiUrl,
|
|
80
|
-
|
|
81
|
-
apiKeySource,
|
|
45
|
+
credentialSource: credential?.source ?? 'none',
|
|
82
46
|
});
|
|
83
47
|
console.error('🔧 ezmodo MCP Server Configuration:');
|
|
84
48
|
console.error(` Environment: ${CONFIG.environment} (build-time)`);
|
|
85
49
|
console.error(` API URL: ${CONFIG.apiUrl}`);
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
50
|
+
if (credential) {
|
|
51
|
+
// Name the source. A server that silently authenticates as whoever the CLI
|
|
52
|
+
// happens to be logged in as, with no way to tell, is worse than one that fails.
|
|
53
|
+
console.error(` Credential: ${credential.detail ?? ''} (from ${credential.source})`);
|
|
54
|
+
} else {
|
|
55
|
+
console.error(' Credential: none yet — sign in with the `authenticate` tool');
|
|
56
|
+
console.error(` Or set EZMODO_API_KEY. Generate a key at: ${CONFIG.settingsUrl}`);
|
|
57
|
+
}
|
|
89
58
|
console.error('');
|
|
90
59
|
|
|
91
60
|
// ============================================================
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent is told when a call cannot be authenticated (#2632).
|
|
3
|
+
*
|
|
4
|
+
* The hard problem this solves: a stdio MCP server is started headlessly by an
|
|
5
|
+
* editor, with no terminal a human is watching. It cannot say "go to this URL".
|
|
6
|
+
* The server's own clear startup message goes to a stderr log nobody opens —
|
|
7
|
+
* which is exactly how #2611 produced an opaque CONNECTION_CLOSED instead of
|
|
8
|
+
* the message that was right there.
|
|
9
|
+
*
|
|
10
|
+
* So don't fight it. Use the channel the agent is already reading: the tool
|
|
11
|
+
* result. The agent shows the URL in chat, the user clicks, the agent retries.
|
|
12
|
+
* This is the pattern the claude.ai connector already uses, and it is better UX
|
|
13
|
+
* than an environment variable rather than a workaround for one.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is DATA in a tool result, never a thrown transport error: a
|
|
16
|
+
* result is something the model can read and act on, an exception is not.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CONFIG } from '../config/index.js';
|
|
20
|
+
|
|
21
|
+
/** Marks the "there is no credential at all" failure, so dispatch can spot it. */
|
|
22
|
+
export const NOT_AUTHENTICATED = 'EZMODO_NOT_AUTHENTICATED';
|
|
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
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The two things a user cannot guess, and will otherwise hit as bare failures.
|
|
44
|
+
*
|
|
45
|
+
* Stated on every sign-in prompt on purpose. Both are consequences of decisions
|
|
46
|
+
* made elsewhere, and neither is discoverable from the error the user would
|
|
47
|
+
* otherwise see.
|
|
48
|
+
*/
|
|
49
|
+
const CAVEATS = [
|
|
50
|
+
'A browser is required. This connector cannot take a username and password ' +
|
|
51
|
+
'directly — direct access grants are disabled on it deliberately, because ' +
|
|
52
|
+
'skipping the browser also skips the consent screen, which is the whole ' +
|
|
53
|
+
'reason for using OAuth here rather than a pasted key.',
|
|
54
|
+
'A brand-new EzModo account belongs to no organization yet, so tools will ' +
|
|
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.',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The payload returned when a sign-in is needed.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {string} [options.authUrl] The URL to open, when a flow has started.
|
|
65
|
+
* @param {string} [options.reason] What went wrong, in one line.
|
|
66
|
+
*/
|
|
67
|
+
export function signInRequired({ authUrl, reason } = {}) {
|
|
68
|
+
return {
|
|
69
|
+
authenticated: false,
|
|
70
|
+
reason: reason || 'No EzModo credential is available.',
|
|
71
|
+
...(authUrl
|
|
72
|
+
? {
|
|
73
|
+
action_required: 'Open this URL in a browser, approve the access, then retry the call.',
|
|
74
|
+
authUrl,
|
|
75
|
+
}
|
|
76
|
+
: {
|
|
77
|
+
action_required:
|
|
78
|
+
'Call the `authenticate` tool to start sign-in. It returns a URL to open.',
|
|
79
|
+
}),
|
|
80
|
+
alternatives: [
|
|
81
|
+
`Set EZMODO_API_KEY in the environment instead — generate a key at ${CONFIG.settingsUrl}. ` +
|
|
82
|
+
'This is the right path for CI and anything headless.',
|
|
83
|
+
],
|
|
84
|
+
notes: CAVEATS,
|
|
85
|
+
};
|
|
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
|
+
}
|