@ezmodo/mcp-server 0.14.0 → 0.14.2
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 +38 -3
- package/index.js +1 -1
- package/lib/auth-guidance.js +118 -3
- package/lib/cli-credential.js +17 -3
- package/lib/create-server.js +85 -4
- package/lib/credentials.js +20 -0
- package/lib/http-client.js +6 -0
- package/lib/logger.js +10 -4
- package/lib/oauth.js +52 -18
- package/lib/version.js +1 -1
- package/package.json +7 -1
- package/tools/auth.js +7 -5
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", "-p", "@ezmodo/mcp-server", "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", "-p", "@ezmodo/mcp-server", "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
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
|
-
|
|
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:
|
|
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 —
|
|
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('');
|
package/lib/auth-guidance.js
CHANGED
|
@@ -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
|
|
38
|
-
'
|
|
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
|
+
'returns an `onboardingUrl` to finish setting up a workspace.',
|
|
39
58
|
];
|
|
40
59
|
|
|
41
60
|
/**
|
|
@@ -51,8 +70,15 @@ export function signInRequired({ authUrl, reason } = {}) {
|
|
|
51
70
|
reason: reason || 'No EzModo credential is available.',
|
|
52
71
|
...(authUrl
|
|
53
72
|
? {
|
|
54
|
-
action_required: '
|
|
73
|
+
action_required: 'Show this URL to the user. Once they have approved it in the browser, retry the call.',
|
|
55
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.',
|
|
56
82
|
}
|
|
57
83
|
: {
|
|
58
84
|
action_required:
|
|
@@ -65,3 +91,92 @@ export function signInRequired({ authUrl, reason } = {}) {
|
|
|
65
91
|
notes: CAVEATS,
|
|
66
92
|
};
|
|
67
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The payload returned when the caller is signed in but belongs to no
|
|
97
|
+
* organization (#2639).
|
|
98
|
+
*
|
|
99
|
+
* Why this is a separate answer from `signInRequired`, and not a widening of
|
|
100
|
+
* it: sign-in already worked. Sending this person back through a browser flow
|
|
101
|
+
* produces the identical token and the identical failure, which is precisely
|
|
102
|
+
* why #2632 kept 403 out of that funnel. What they need is the OTHER half of
|
|
103
|
+
* onboarding — a workspace — and EzModo already has a surface for it.
|
|
104
|
+
*
|
|
105
|
+
* SO THIS DOES NOT INVENT A MECHANISM. The web app routes a signed-in user
|
|
106
|
+
* with no organizations to /onboarding, which offers a personal workspace, a
|
|
107
|
+
* new team, or /join — workspace discovery, which lists organizations matching
|
|
108
|
+
* a verified email domain and joins none of them without a click. Matching an
|
|
109
|
+
* organization here instead would be a second, quieter copy of that consent
|
|
110
|
+
* decision, and the quiet copy is the one that gets it wrong.
|
|
111
|
+
*
|
|
112
|
+
* Returned on BOTH surfaces, unlike the sign-in prompt. Over the connector
|
|
113
|
+
* Claude owns the OAuth, so sign-in advice from here would be wrong — but the
|
|
114
|
+
* missing workspace is ours either way, and a claude.ai user hits it exactly
|
|
115
|
+
* as a local one does.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} options
|
|
118
|
+
* @param {string} [options.reason] What the API said, in one line.
|
|
119
|
+
*/
|
|
120
|
+
export function organizationRequired({ reason } = {}) {
|
|
121
|
+
return {
|
|
122
|
+
authenticated: true,
|
|
123
|
+
organization: null,
|
|
124
|
+
reason:
|
|
125
|
+
reason ||
|
|
126
|
+
'This account is signed in but does not belong to any EzModo organization.',
|
|
127
|
+
action_required:
|
|
128
|
+
'Open the URL below and finish setting up a workspace, then retry the call. ' +
|
|
129
|
+
'You can create a personal workspace, create a team, or ask to join an ' +
|
|
130
|
+
'organization your verified email already matches.',
|
|
131
|
+
onboardingUrl: `${CONFIG.webUrl}/onboarding`,
|
|
132
|
+
notes: [
|
|
133
|
+
'Sign-in itself succeeded. Signing in again will not change this, and ' +
|
|
134
|
+
'no credential — OAuth token or API key — can substitute for belonging ' +
|
|
135
|
+
'to an organization.',
|
|
136
|
+
'If you expected to already be a member of one, you are probably signed ' +
|
|
137
|
+
`in as a different identity than you think. Check which email ${CONFIG.webUrl}/onboarding ` +
|
|
138
|
+
'shows, and ask an administrator to invite that exact address.',
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The payload returned when sign-in succeeded but no account could be created
|
|
145
|
+
* for it, because the email already belongs to a different EzModo identity
|
|
146
|
+
* (#2652).
|
|
147
|
+
*
|
|
148
|
+
* The reason this is not folded into `organizationRequired`: both used to look
|
|
149
|
+
* identical from outside — a signed-in caller getting nothing back — and the
|
|
150
|
+
* fix for one is a dead end for the other. Creating a workspace runs the same
|
|
151
|
+
* insert against the same unique index and fails the same way. Telling someone
|
|
152
|
+
* to go do that is worse than telling them nothing, because they will believe
|
|
153
|
+
* it and try.
|
|
154
|
+
*
|
|
155
|
+
* There is deliberately no self-service action here. Merging two identities
|
|
156
|
+
* onto one account is exactly the operation that must not be automated from an
|
|
157
|
+
* unauthenticated-by-the-other-party direction, so the honest answer is the
|
|
158
|
+
* original sign-in method, or a human.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} options
|
|
161
|
+
* @param {string} [options.reason] What the API said, in one line.
|
|
162
|
+
*/
|
|
163
|
+
export function emailAlreadyRegistered({ reason } = {}) {
|
|
164
|
+
return {
|
|
165
|
+
authenticated: true,
|
|
166
|
+
accountProvisioned: false,
|
|
167
|
+
reason:
|
|
168
|
+
reason ||
|
|
169
|
+
'This email address already belongs to a different EzModo account, so no ' +
|
|
170
|
+
'account could be created for the identity you signed in with.',
|
|
171
|
+
action_required:
|
|
172
|
+
'Sign in the way you originally did — a password, or whichever of Google, ' +
|
|
173
|
+
'Microsoft, GitHub or Apple you used first. If you need the two linked, ' +
|
|
174
|
+
'contact support.',
|
|
175
|
+
notes: [
|
|
176
|
+
'Creating a workspace will NOT fix this, and neither will signing in ' +
|
|
177
|
+
'again through the same provider. Nothing is wrong with the sign-in; ' +
|
|
178
|
+
'the account simply could not be created.',
|
|
179
|
+
`You can check which address is in use at ${CONFIG.webUrl}/settings.`,
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
package/lib/cli-credential.js
CHANGED
|
@@ -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
|
|
88
|
-
if (
|
|
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.
|
package/lib/create-server.js
CHANGED
|
@@ -26,8 +26,17 @@ 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 {
|
|
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';
|
|
38
|
+
import { getApiKey } from './env.js';
|
|
39
|
+
import { startSignIn as defaultStartSignIn } from '../handlers/auth.js';
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* @param {object} [options]
|
|
@@ -47,13 +56,34 @@ import { getInstructions } from './instructions.js';
|
|
|
47
56
|
*
|
|
48
57
|
* 403 deliberately does NOT count. That means signed in but not permitted,
|
|
49
58
|
* 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.
|
|
59
|
+
* through a sign-in that cannot fix it is worse than saying nothing. That case
|
|
60
|
+
* gets its own answer below — saying nothing was the placeholder, not the plan.
|
|
51
61
|
*/
|
|
52
62
|
function isAuthFailure(error) {
|
|
53
63
|
return error?.code === NOT_AUTHENTICATED || error?.status === 401;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Whether a failure means "signed in, but in no organization" (#2639).
|
|
68
|
+
*
|
|
69
|
+
* Keyed on the API's code, never on the message: the API owns the wording and
|
|
70
|
+
* will improve it, and a prose match that silently stops matching degrades to
|
|
71
|
+
* the bare 403 this exists to replace — the failure would be invisible, since
|
|
72
|
+
* the call still fails either way, just uselessly.
|
|
73
|
+
*/
|
|
74
|
+
function isNoOrganization(error) {
|
|
75
|
+
return error?.code === NO_ORGANIZATION;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a failure means "signed in, but this email is already spoken for"
|
|
80
|
+
* (#2652). Same keying, and the same reason for it.
|
|
81
|
+
*/
|
|
82
|
+
function isEmailAlreadyRegistered(error) {
|
|
83
|
+
return error?.code === EMAIL_ALREADY_REGISTERED;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createServer({ surface = 'local', startSignIn = defaultStartSignIn } = {}) {
|
|
57
87
|
const log = getLogger();
|
|
58
88
|
|
|
59
89
|
// Filtered ONCE here rather than at each call site, so listing and dispatch
|
|
@@ -116,9 +146,60 @@ export function createServer({ surface = 'local' } = {}) {
|
|
|
116
146
|
// every tool goes through it, so none of them can be missed or drift
|
|
117
147
|
// (#2632). Only on the local surface; over the connector Claude owns the
|
|
118
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.
|
|
119
165
|
if (surface !== 'remote' && isAuthFailure(error)) {
|
|
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) }] };
|
|
183
|
+
}
|
|
184
|
+
// Answered on EVERY surface, unlike the sign-in prompt above. Sign-in
|
|
185
|
+
// advice is surface-specific because over the connector Claude owns the
|
|
186
|
+
// OAuth; a missing workspace is ours either way, and a claude.ai user
|
|
187
|
+
// hits it exactly as a local one does.
|
|
188
|
+
if (isNoOrganization(error)) {
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{ type: 'text', text: JSON.stringify(organizationRequired({ reason: errMsg }), null, 2) },
|
|
192
|
+
],
|
|
193
|
+
isError: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// Checked separately from the branch above, never merged into it: these
|
|
197
|
+
// two look alike from outside and their answers point opposite ways.
|
|
198
|
+
if (isEmailAlreadyRegistered(error)) {
|
|
120
199
|
return {
|
|
121
|
-
content: [
|
|
200
|
+
content: [
|
|
201
|
+
{ type: 'text', text: JSON.stringify(emailAlreadyRegistered({ reason: errMsg }), null, 2) },
|
|
202
|
+
],
|
|
122
203
|
isError: true,
|
|
123
204
|
};
|
|
124
205
|
}
|
package/lib/credentials.js
CHANGED
|
@@ -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/http-client.js
CHANGED
|
@@ -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/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 ~/.
|
|
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 = /^
|
|
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, `
|
|
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(), '.
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', '\'': ''',
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ezmodo/mcp-server",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
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.
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'retry the original call once they
|
|
18
|
-
'
|
|
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: {
|