@ezmodo/mcp-server 0.13.5 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +67 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +47 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +19 -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/lib/oauth.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Code + PKCE sign-in, run by the MCP server for itself (#2631).
|
|
3
|
+
*
|
|
4
|
+
* Ported from cli/src/lib/oauth-login.ts, oauth-callback-server.ts and
|
|
5
|
+
* token-refresh.ts rather than imported from them. This package is published to
|
|
6
|
+
* npm and launched with `npx @ezmodo/mcp-server`; it must not require the CLI
|
|
7
|
+
* to be installed. lib/cli-credential.js re-implements the CLI's credential
|
|
8
|
+
* read for the same reason, and records the same trade-off: when the CLI's
|
|
9
|
+
* formats move, a copy goes stale and stops working, which is the safe
|
|
10
|
+
* direction to fail in.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS DIFFERENT FROM THE CLI'S COPY, and why:
|
|
13
|
+
*
|
|
14
|
+
* - An EPHEMERAL loopback port, not the CLI's fixed 19838. The `ezmodo-mcp`
|
|
15
|
+
* client registers http://localhost/* and http://127.0.0.1/*, so any port
|
|
16
|
+
* matches; a fixed port would collide with a concurrent `ezmodo auth
|
|
17
|
+
* login`, which is precisely when someone is likely to be signing in.
|
|
18
|
+
* - NOTHING is written to stdout. Over stdio, stdout IS the MCP protocol
|
|
19
|
+
* channel — a stray console.log is a protocol violation that corrupts the
|
|
20
|
+
* session. The CLI's copy prints progress freely because it owns its
|
|
21
|
+
* terminal. Here, diagnostics go to the logger (stderr) and anything the
|
|
22
|
+
* user must read is returned to the caller to surface (#2632).
|
|
23
|
+
* - Refresh is single-flight. Tool calls run concurrently, so several can
|
|
24
|
+
* find the same expired token at once; without this they would each burn a
|
|
25
|
+
* refresh token and all but one would fail, because Keycloak rotates it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash, randomBytes } from 'crypto';
|
|
29
|
+
import { createServer } from 'http';
|
|
30
|
+
import fetch from 'node-fetch';
|
|
31
|
+
import { getKeycloakEndpoints, getScopes } from './oauth-config.js';
|
|
32
|
+
import { clearTokens, isExpired, readTokens, writeTokens } from './token-store.js';
|
|
33
|
+
import { getLogger } from './logger.js';
|
|
34
|
+
|
|
35
|
+
/** How long to wait for the user to finish in the browser before giving up. */
|
|
36
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// PKCE primitives (RFC 7636)
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
function generateState() {
|
|
43
|
+
return randomBytes(32).toString('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function generateCodeVerifier() {
|
|
47
|
+
return randomBytes(32)
|
|
48
|
+
.toString('base64url')
|
|
49
|
+
.replace(/[^a-zA-Z0-9\-._~]/g, '')
|
|
50
|
+
.substring(0, 128);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function generateCodeChallenge(verifier) {
|
|
54
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the `sub`, `email` and `exp` out of a JWT WITHOUT verifying it.
|
|
59
|
+
*
|
|
60
|
+
* Safe here, and only here: this token came from a TLS connection to the token
|
|
61
|
+
* endpoint moments ago, and nothing security-relevant is decided from these
|
|
62
|
+
* claims — they are stored so a human can be told which account is signed in.
|
|
63
|
+
* The API verifies the signature on every request, which is where that check
|
|
64
|
+
* belongs.
|
|
65
|
+
*/
|
|
66
|
+
function decodeJwtPayload(token) {
|
|
67
|
+
try {
|
|
68
|
+
const parts = token.split('.');
|
|
69
|
+
if (parts.length !== 3) return {};
|
|
70
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// Loopback callback capture
|
|
78
|
+
// ============================================================
|
|
79
|
+
|
|
80
|
+
const DONE_PAGE = (heading, detail) => `<!doctype html>
|
|
81
|
+
<html lang="en"><head><meta charset="utf-8"><title>EzModo</title>
|
|
82
|
+
<style>
|
|
83
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e8eaed;
|
|
84
|
+
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
85
|
+
main{text-align:center;max-width:26rem;padding:2rem}
|
|
86
|
+
h1{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}
|
|
87
|
+
p{color:#9aa0a6;line-height:1.5;margin:0}
|
|
88
|
+
</style></head>
|
|
89
|
+
<body><main><h1>${heading}</h1><p>${detail}</p></main></body></html>`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Listen on an ephemeral loopback port for the authorization redirect.
|
|
93
|
+
*
|
|
94
|
+
* Resolves with the code once one arrives. The redirect URI is returned before
|
|
95
|
+
* the code is, because the caller needs the port to build the authorize URL —
|
|
96
|
+
* hence the two-stage shape rather than a single promise.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} expectedState
|
|
99
|
+
* @returns {Promise<{ redirectUri: string, code: Promise<string>, close: () => void }>}
|
|
100
|
+
*/
|
|
101
|
+
function startCallbackServer(expectedState) {
|
|
102
|
+
return new Promise((resolveReady, rejectReady) => {
|
|
103
|
+
let settle;
|
|
104
|
+
const code = new Promise((resolve, reject) => {
|
|
105
|
+
settle = { resolve, reject };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let finished = false;
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
111
|
+
if (url.pathname === '/favicon.ico') {
|
|
112
|
+
res.writeHead(204).end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fail = (message) => {
|
|
117
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
118
|
+
res.end(DONE_PAGE('Sign-in failed', message));
|
|
119
|
+
if (!finished) {
|
|
120
|
+
finished = true;
|
|
121
|
+
settle.reject(new Error(message));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const error = url.searchParams.get('error');
|
|
126
|
+
if (error) {
|
|
127
|
+
// Keycloak reports a refused consent here rather than by not
|
|
128
|
+
// redirecting, so this is the ordinary "user clicked Cancel" path.
|
|
129
|
+
const description = url.searchParams.get('error_description') || error;
|
|
130
|
+
fail(description);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const received = url.searchParams.get('code');
|
|
135
|
+
const state = url.searchParams.get('state');
|
|
136
|
+
if (!received) {
|
|
137
|
+
fail('No authorization code in the callback.');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (state !== expectedState) {
|
|
141
|
+
// The CSRF check. A mismatch means this redirect was not the one we
|
|
142
|
+
// started, so the code must not be exchanged.
|
|
143
|
+
fail('State mismatch — this sign-in did not come from this request.');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
148
|
+
res.end(DONE_PAGE('You are signed in', 'You can close this tab and return to your editor.'));
|
|
149
|
+
if (!finished) {
|
|
150
|
+
finished = true;
|
|
151
|
+
settle.resolve(received);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
server.on('error', (error) => {
|
|
156
|
+
if (!finished) {
|
|
157
|
+
finished = true;
|
|
158
|
+
settle.reject(error);
|
|
159
|
+
}
|
|
160
|
+
rejectReady(error);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
if (!finished) {
|
|
165
|
+
finished = true;
|
|
166
|
+
settle.reject(new Error('Timed out waiting for the browser sign-in to complete.'));
|
|
167
|
+
}
|
|
168
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
169
|
+
// Do not hold the process open purely to wait for a browser.
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
|
|
172
|
+
const close = () => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
server.close();
|
|
175
|
+
};
|
|
176
|
+
code.then(close, close);
|
|
177
|
+
|
|
178
|
+
// Port 0 = let the OS pick. 127.0.0.1 rather than a wildcard bind: this
|
|
179
|
+
// socket briefly accepts an authorization code, and it has no business
|
|
180
|
+
// being reachable from the network.
|
|
181
|
+
server.listen(0, '127.0.0.1', () => {
|
|
182
|
+
const { port } = server.address();
|
|
183
|
+
resolveReady({ redirectUri: `http://127.0.0.1:${port}/callback`, code, close });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================
|
|
189
|
+
// Token endpoint
|
|
190
|
+
// ============================================================
|
|
191
|
+
|
|
192
|
+
async function postToken(body) {
|
|
193
|
+
const endpoints = getKeycloakEndpoints();
|
|
194
|
+
const response = await fetch(endpoints.token, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
197
|
+
body: new URLSearchParams(body).toString(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
const error = new Error(`Token request failed (${response.status}): ${text}`);
|
|
203
|
+
error.status = response.status;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
return JSON.parse(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Turn a Keycloak token response into what the store holds. */
|
|
210
|
+
function toStoredTokens(response) {
|
|
211
|
+
const claims = decodeJwtPayload(response.access_token);
|
|
212
|
+
return {
|
|
213
|
+
accessToken: response.access_token,
|
|
214
|
+
refreshToken: response.refresh_token,
|
|
215
|
+
expiresAt: new Date(Date.now() + (response.expires_in ?? 60) * 1000).toISOString(),
|
|
216
|
+
userId: claims.sub,
|
|
217
|
+
email: claims.email || claims.preferred_username,
|
|
218
|
+
scope: response.scope,
|
|
219
|
+
issuer: getKeycloakEndpoints().issuer,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================
|
|
224
|
+
// Sign-in
|
|
225
|
+
// ============================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the authorize URL and start listening for its redirect.
|
|
229
|
+
*
|
|
230
|
+
* Split from the wait deliberately: the caller needs the URL to hand to the
|
|
231
|
+
* user (in a tool result, or a browser it opened) BEFORE anyone can complete
|
|
232
|
+
* the flow. Returning both at once would mean the URL only became available
|
|
233
|
+
* after it was already too late to show it.
|
|
234
|
+
*
|
|
235
|
+
* @returns {Promise<{ authUrl: string, complete: () => Promise<StoredTokens>, cancel: () => void }>}
|
|
236
|
+
*/
|
|
237
|
+
export async function beginLogin() {
|
|
238
|
+
const endpoints = getKeycloakEndpoints();
|
|
239
|
+
const state = generateState();
|
|
240
|
+
const verifier = generateCodeVerifier();
|
|
241
|
+
const challenge = generateCodeChallenge(verifier);
|
|
242
|
+
|
|
243
|
+
const { redirectUri, code, close } = await startCallbackServer(state);
|
|
244
|
+
|
|
245
|
+
const authUrl = new URL(endpoints.authorization);
|
|
246
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
247
|
+
authUrl.searchParams.set('client_id', endpoints.clientId);
|
|
248
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
249
|
+
authUrl.searchParams.set('scope', getScopes());
|
|
250
|
+
authUrl.searchParams.set('state', state);
|
|
251
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
252
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
253
|
+
|
|
254
|
+
getLogger().debug('OAuth sign-in started', { redirectUri, environment: endpoints.environment });
|
|
255
|
+
|
|
256
|
+
const complete = async () => {
|
|
257
|
+
const authorizationCode = await code;
|
|
258
|
+
const response = await postToken({
|
|
259
|
+
grant_type: 'authorization_code',
|
|
260
|
+
client_id: endpoints.clientId,
|
|
261
|
+
code: authorizationCode,
|
|
262
|
+
redirect_uri: redirectUri,
|
|
263
|
+
code_verifier: verifier,
|
|
264
|
+
});
|
|
265
|
+
const tokens = toStoredTokens(response);
|
|
266
|
+
writeTokens(tokens);
|
|
267
|
+
getLogger().info('OAuth sign-in complete', { email: tokens.email });
|
|
268
|
+
return tokens;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return { authUrl: authUrl.toString(), complete, cancel: close };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================
|
|
275
|
+
// Refresh
|
|
276
|
+
// ============================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* In-flight refresh, shared by every caller that arrives while it runs.
|
|
280
|
+
*
|
|
281
|
+
* Keycloak ROTATES refresh tokens: the old one dies the moment the new one is
|
|
282
|
+
* issued. Two concurrent refreshes therefore do not merely waste a round trip,
|
|
283
|
+
* they race to invalidate each other, and the loser signs the user out. One
|
|
284
|
+
* promise, awaited by all.
|
|
285
|
+
*/
|
|
286
|
+
let refreshInFlight = null;
|
|
287
|
+
|
|
288
|
+
async function refreshTokens(tokens) {
|
|
289
|
+
const endpoints = getKeycloakEndpoints();
|
|
290
|
+
try {
|
|
291
|
+
const response = await postToken({
|
|
292
|
+
grant_type: 'refresh_token',
|
|
293
|
+
client_id: endpoints.clientId,
|
|
294
|
+
refresh_token: tokens.refreshToken,
|
|
295
|
+
});
|
|
296
|
+
const refreshed = toStoredTokens(response);
|
|
297
|
+
// Keycloak may omit a new refresh token; keep the old one when it does,
|
|
298
|
+
// or the next refresh has nothing to present.
|
|
299
|
+
if (!refreshed.refreshToken) refreshed.refreshToken = tokens.refreshToken;
|
|
300
|
+
writeTokens(refreshed);
|
|
301
|
+
return refreshed;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
// 400 from the token endpoint on a refresh means the grant is dead —
|
|
304
|
+
// expired, revoked, or already rotated. Keeping it would retry forever
|
|
305
|
+
// against something that can never succeed, so drop it and let the caller
|
|
306
|
+
// ask for a fresh sign-in.
|
|
307
|
+
if (error.status === 400) {
|
|
308
|
+
getLogger().info('Refresh token no longer valid; signing out', { error: error.message });
|
|
309
|
+
clearTokens();
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
// Anything else (network, 5xx) is plausibly transient. Leave the stored
|
|
313
|
+
// tokens alone so a later call can try again.
|
|
314
|
+
getLogger().warn('Token refresh failed', { error: error.message });
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A usable access token, refreshing if needed, or null when a sign-in is due.
|
|
321
|
+
*
|
|
322
|
+
* Null is the ONLY failure mode: every caller is on the path of a tool call,
|
|
323
|
+
* and "you need to sign in" is a message the agent can act on, whereas a thrown
|
|
324
|
+
* error is one it cannot.
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<string|null>}
|
|
327
|
+
*/
|
|
328
|
+
export async function getAccessToken() {
|
|
329
|
+
const tokens = readTokens();
|
|
330
|
+
if (!tokens) return null;
|
|
331
|
+
if (!isExpired(tokens)) return tokens.accessToken;
|
|
332
|
+
if (!tokens.refreshToken) return null;
|
|
333
|
+
|
|
334
|
+
if (!refreshInFlight) {
|
|
335
|
+
refreshInFlight = refreshTokens(tokens).finally(() => {
|
|
336
|
+
refreshInFlight = null;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const refreshed = await refreshInFlight;
|
|
340
|
+
return refreshed?.accessToken ?? null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Who is signed in, for diagnostics. Null when nobody is. */
|
|
344
|
+
export function getSignedInIdentity() {
|
|
345
|
+
const tokens = readTokens();
|
|
346
|
+
if (!tokens) return null;
|
|
347
|
+
return { email: tokens.email, userId: tokens.userId, scope: tokens.scope };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Forget the stored tokens. */
|
|
351
|
+
export function signOut() {
|
|
352
|
+
return clearTokens();
|
|
353
|
+
}
|
package/lib/remote-tools.js
CHANGED
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
|
|
42
42
|
/** Tools that operate on the local machine and are never served remotely. */
|
|
43
43
|
export const LOCAL_ONLY_TOOLS = Object.freeze([
|
|
44
|
+
// Not a filesystem tool, but local for the same reason: over the connector
|
|
45
|
+
// Claude completes its own OAuth before any tool call, so a second sign-in
|
|
46
|
+
// offered there would be inert and confusing (#2632). It also opens a
|
|
47
|
+
// browser and binds a loopback port, neither of which means anything in a
|
|
48
|
+
// container.
|
|
49
|
+
'authenticate',
|
|
44
50
|
'detect_git_repository',
|
|
45
51
|
'get_current_project_context',
|
|
46
52
|
'initialize_project_context',
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk store for the OAuth tokens this server obtains for itself (#2631).
|
|
3
|
+
*
|
|
4
|
+
* Lives at <config dir>/mcp-oauth.json, beside the CLI's own `credentials`
|
|
5
|
+
* file. A DISTINCT filename on purpose: the CLI owns `credentials` and
|
|
6
|
+
* `oauth.json`, and two programs writing one file is how a working login
|
|
7
|
+
* disappears the next time the other one runs. This server only ever reads the
|
|
8
|
+
* CLI's files (see lib/cli-credential.js) and only ever writes its own.
|
|
9
|
+
*
|
|
10
|
+
* Three properties this module is responsible for:
|
|
11
|
+
*
|
|
12
|
+
* 1. It never throws. It sits on the path every tool call takes to resolve a
|
|
13
|
+
* credential. A malformed or unreadable file must degrade to "not signed
|
|
14
|
+
* in" — which prompts a fresh sign-in and fixes itself — rather than
|
|
15
|
+
* taking down the server, which does not.
|
|
16
|
+
* 2. It writes 0600, and creates the directory 0700. These are bearer
|
|
17
|
+
* tokens for the user's whole account; a world-readable file in a shared
|
|
18
|
+
* home directory hands the account over.
|
|
19
|
+
* 3. It writes atomically, via a temp file in the same directory and a
|
|
20
|
+
* rename. A torn write here is indistinguishable from corruption, and
|
|
21
|
+
* corruption logs a user out.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { configDir } from './user-paths.js';
|
|
27
|
+
import { getLogger } from './logger.js';
|
|
28
|
+
|
|
29
|
+
const FILENAME = 'mcp-oauth.json';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Treat a token as expired this many milliseconds BEFORE it actually expires.
|
|
33
|
+
*
|
|
34
|
+
* Without a margin, a token that passes the check can still be rejected by the
|
|
35
|
+
* time the request lands — clock skew between this machine and Keycloak, plus
|
|
36
|
+
* the request's own flight time. Thirty seconds costs nothing (the refresh is
|
|
37
|
+
* one round trip) and removes a class of intermittent 401 that would look like
|
|
38
|
+
* a server bug rather than a clock.
|
|
39
|
+
*/
|
|
40
|
+
const EXPIRY_MARGIN_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
function tokenPath() {
|
|
43
|
+
return join(configDir(), FILENAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} StoredTokens
|
|
48
|
+
* @property {string} accessToken
|
|
49
|
+
* @property {string} [refreshToken]
|
|
50
|
+
* @property {string} expiresAt ISO 8601
|
|
51
|
+
* @property {string} [userId]
|
|
52
|
+
* @property {string} [email]
|
|
53
|
+
* @property {string} [scope]
|
|
54
|
+
* @property {string} [issuer] Which Keycloak issued these
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the stored tokens, or null if there are none to read.
|
|
59
|
+
*
|
|
60
|
+
* @returns {StoredTokens|null}
|
|
61
|
+
*/
|
|
62
|
+
export function readTokens() {
|
|
63
|
+
const path = tokenPath();
|
|
64
|
+
if (!existsSync(path)) return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
67
|
+
if (!parsed || typeof parsed.accessToken !== 'string' || !parsed.accessToken.trim()) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Malformed, truncated, or unreadable. Reporting "not signed in" sends the
|
|
73
|
+
// user through a sign-in that overwrites it; throwing would strand them.
|
|
74
|
+
getLogger().warn('Ignoring unreadable OAuth token file', { path, error: error.message });
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Persist tokens, replacing whatever was there.
|
|
81
|
+
*
|
|
82
|
+
* @param {StoredTokens} tokens
|
|
83
|
+
* @returns {boolean} whether the write landed
|
|
84
|
+
*/
|
|
85
|
+
export function writeTokens(tokens) {
|
|
86
|
+
const dir = configDir();
|
|
87
|
+
const path = tokenPath();
|
|
88
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
91
|
+
writeFileSync(temp, JSON.stringify(tokens, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
92
|
+
renameSync(temp, path);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
getLogger().warn('Could not persist OAuth tokens', { path, error: error.message });
|
|
96
|
+
try {
|
|
97
|
+
if (existsSync(temp)) unlinkSync(temp);
|
|
98
|
+
} catch {
|
|
99
|
+
// Nothing useful to do about a leftover temp file.
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Forget the stored tokens. Used by sign-out and by an unrecoverable refresh. */
|
|
106
|
+
export function clearTokens() {
|
|
107
|
+
const path = tokenPath();
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(path)) unlinkSync(path);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
getLogger().warn('Could not clear OAuth tokens', { path, error: error.message });
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether an access token is past use, margin included.
|
|
119
|
+
*
|
|
120
|
+
* Missing or unparseable expiry counts as expired: if we cannot tell, the safe
|
|
121
|
+
* answer is the one that triggers a refresh rather than the one that sends a
|
|
122
|
+
* possibly-dead token to the API.
|
|
123
|
+
*
|
|
124
|
+
* @param {StoredTokens|null} tokens
|
|
125
|
+
*/
|
|
126
|
+
export function isExpired(tokens) {
|
|
127
|
+
if (!tokens?.expiresAt) return true;
|
|
128
|
+
const expiry = Date.parse(tokens.expiresAt);
|
|
129
|
+
if (Number.isNaN(expiry)) return true;
|
|
130
|
+
return Date.now() >= expiry - EXPIRY_MARGIN_MS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The path tokens are stored at, for diagnostics and messages. */
|
|
134
|
+
export function getTokenPath() {
|
|
135
|
+
return tokenPath();
|
|
136
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this server keeps per-user state on disk.
|
|
3
|
+
*
|
|
4
|
+
* Extracted so the credential reader and the OAuth token store cannot disagree
|
|
5
|
+
* about where "the ezmodo config directory" is. They had better not: the two
|
|
6
|
+
* files live side by side, and a writer that picks a different directory from
|
|
7
|
+
* the reader produces a login that appears to succeed and then is never found
|
|
8
|
+
* again.
|
|
9
|
+
*
|
|
10
|
+
* `.config/ezmodo` is current, `.config/zephly` the pre-rebrand name still
|
|
11
|
+
* present in older installs. Current always wins; legacy is READ-ONLY. Nothing
|
|
12
|
+
* here ever writes to the legacy directory — migrating it is the CLI's job
|
|
13
|
+
* (cli/src/lib/user-paths.ts owns that), and a second migrator racing the first
|
|
14
|
+
* over the same files is worse than not migrating at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every directory to SEARCH, current first.
|
|
22
|
+
*
|
|
23
|
+
* @returns {string[]}
|
|
24
|
+
*/
|
|
25
|
+
export function configDirs() {
|
|
26
|
+
const home = homedir();
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const base = process.env.APPDATA || home;
|
|
29
|
+
return [join(base, 'ezmodo'), join(base, 'zephly')];
|
|
30
|
+
}
|
|
31
|
+
return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The single directory to WRITE to. Always the current name.
|
|
36
|
+
*
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function configDir() {
|
|
40
|
+
return configDirs()[0];
|
|
41
|
+
}
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ezmodo/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "MCP server for ezmodo - AI-first project management",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"test:watch": "BUILD_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --watch",
|
|
22
22
|
"test:coverage": "BUILD_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
|
23
23
|
"test:smoke": "BUILD_ENV=development node test.js",
|
|
24
|
-
"lint": "eslint . --fix"
|
|
24
|
+
"lint": "eslint . --fix",
|
|
25
|
+
"generate:instructions": "node scripts/build-instructions.mjs",
|
|
26
|
+
"generate:prompts": "node scripts/build-prompts.mjs"
|
|
25
27
|
},
|
|
26
28
|
"keywords": [
|
|
27
29
|
"mcp",
|
|
@@ -36,14 +38,15 @@
|
|
|
36
38
|
"access": "public"
|
|
37
39
|
},
|
|
38
40
|
"files": [
|
|
39
|
-
"
|
|
40
|
-
"http.js",
|
|
41
|
+
"README.md",
|
|
41
42
|
"config/",
|
|
42
43
|
"handlers/",
|
|
44
|
+
"http.js",
|
|
45
|
+
"index.js",
|
|
43
46
|
"lib/",
|
|
44
47
|
"prompts/",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
48
|
+
"scripts/",
|
|
49
|
+
"tools/"
|
|
47
50
|
],
|
|
48
51
|
"homepage": "https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp",
|
|
49
52
|
"bugs": {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from plugins/ezmodo/commands/ by scripts/build-prompts.mjs.
|
|
5
|
+
* Edit the command, then run:
|
|
6
|
+
*
|
|
7
|
+
* npm run generate:prompts --workspace=@ezmodo/mcp-server
|
|
8
|
+
*
|
|
9
|
+
* __tests__/prompts.test.js fails if this drifts from the commands.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const COMMAND_PROMPTS = [
|
|
13
|
+
{
|
|
14
|
+
"name": "start",
|
|
15
|
+
"description": "Create an EzModo task for what you are about to build, and start it",
|
|
16
|
+
"argumentHint": "[what you are about to work on]",
|
|
17
|
+
"surfaces": [
|
|
18
|
+
"local",
|
|
19
|
+
"remote"
|
|
20
|
+
],
|
|
21
|
+
"body": "Start tracked work on: **$ARGUMENTS**\n\nFollow the work-tracking contract in this server's instructions. In short:\n\n1. `get_current_project_context()` — cache the `projectId`, note the components,\n tags and `terminology`.\n2. `get_context` with a keyword query drawn from the request above. Read what\n comes back before writing anything: it tells you which files exist, what\n patterns they follow, and what the change will touch.\n3. `resolve_links` on the paths you expect to change. A component you did not\n expect means the work is broader than the request sounds.\n4. Create the work:\n - **Single scope** (a fix, a small feature, a config or docs change) —\n `manage_task action:\"create\"` with `status:\"in_progress\"`, a description\n that says why/where/how, steps that name real files, `componentIds` for\n every component involved, and the right `taskType`.\n - **Multi scope** (spanning areas, or a large refactor) — `manage_epic\n action:\"create\"` with its child tasks in the same request, ordered by\n dependency.\n\nThen report the task number and web URL and begin. Do not edit anything before\nthe task exists — a task created afterwards is a task written from memory.\n\nIf no `.ezmodo/config.json` is found, say so and stop rather than guessing at a\nproject."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "resume",
|
|
25
|
+
"description": "Load an EzModo task or epic by number and continue where the last session stopped",
|
|
26
|
+
"argumentHint": "<task number, epic number, or id>",
|
|
27
|
+
"surfaces": [
|
|
28
|
+
"local",
|
|
29
|
+
"remote"
|
|
30
|
+
],
|
|
31
|
+
"body": "Resume: **$ARGUMENTS**\n\nLoad it **directly** — `get_task` (with `taskNumber` + `projectId`, or `taskId`)\nor `get_epic`. Do not search; a number or id is an exact address, and\n`search_tasks` / `search_epics` are for when you have neither.\n\nYou will need the `projectId` from `get_current_project_context()` to resolve a\ntask number.\n\nThen, before doing anything:\n\n1. Read **every** knowledge item. That is where the previous session put its\n reasoning — root causes, decisions and what they rejected, blockers.\n2. Look for knowledge tagged `progress-checkpoint` for the latest status.\n3. Note which steps are already complete. Do not redo them.\n\nReport back: what the task is, what has been done, what the last session\nlearned that changes how you would approach the rest, and which step you are\npicking up. Then continue from there, following the work-tracking contract in this server's instructions for the rest.\n\nIf the task is already `in_review` or `completed`, say so and ask before\nreopening it."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "submit",
|
|
35
|
+
"description": "Finish the active EzModo task — steps, knowledge, commit links, then in_review",
|
|
36
|
+
"argumentHint": "[anything to note in the completion summary]",
|
|
37
|
+
"surfaces": [
|
|
38
|
+
"local"
|
|
39
|
+
],
|
|
40
|
+
"body": "Close out the active task.\n\nHEAD: run `git rev-parse HEAD` and use its output\nRecent commits: run `git log --oneline -5` and use its output\n\nWork through, in order:\n\n1. **Steps** — `toggleSteps` for everything now done. If work happened that no\n step covered, `addStep` it first rather than leaving it unrecorded. If a step\n was deliberately not done, leave it open and say why in the notes.\n2. **Knowledge** — `addKnowledge` for anything the next session would have to\n rediscover: root causes (`fact`), decisions and what they rejected\n (`decision`), blockers (`fact`). Specific: file paths, function names, exact\n error messages.\n3. **Commits** — `link_commit` for every commit not yet linked, using the full\n 40-character SHA above. A short SHA is rejected.\n4. **Link suggestions** — check `list_agent_suggestions action:\"link\"` for this\n task and clear the queue with `resolve_link_suggestions`. Reject with a real\n reason; leaving them pending is the only wrong outcome.\n5. **Test cases** — only if `autoGenerateTestCases` is true in the project\n context. 3-6 cases covering happy path, edges and errors.\n6. **Submit** — `manage_task action:\"update\"` with `status:\"in_review\"` and\n `completionNotes`.\n\nThe notes are the deliverable. They must say what was done, what was verified\nand how, and — explicitly — anything in scope that was **not** done and why.\nA summary that omits the gap is worse than none, because the reviewer trusts it.\n\nDo **not** call `manage_task action:\"complete\"`. A human completes the task.\n\nAnything to include: $ARGUMENTS"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "untracked",
|
|
44
|
+
"description": "Retroactively capture work already in progress that has no EzModo task",
|
|
45
|
+
"argumentHint": "[what the work was, if the diff does not make it obvious]",
|
|
46
|
+
"surfaces": [
|
|
47
|
+
"local",
|
|
48
|
+
"remote"
|
|
49
|
+
],
|
|
50
|
+
"body": "Capture the current uncommitted work as a task.\n\nCurrent branch: run `git rev-parse --abbrev-ref HEAD` and use its output\nChanged files: run `git status --porcelain` and use its output\n\nUse `report_untracked_work` with:\n\n- `projectId` from `get_current_project_context()`\n- `title` — concise, describing what was actually done\n- `description` — what and **why**. Do not restate the branch or file list; they\n are appended automatically as evidence.\n- `changedFiles` — the paths above\n- `branch` — as above\n- `componentIds` — resolve the changed paths with `resolve_links` rather than\n guessing; untracked work often spans more than one area, which is part of why\n it went untracked\n- `origin`:\n - `discovered` — found while working on another task (set `discoveredDuringTaskId`)\n - `scope-creep` — went beyond the active task's scope (set `discoveredDuringTaskId`)\n - `rework` — redoing prior work\n - `untracked` — unplanned standalone work (the default)\n\nIf there is an active task in this session, prefer `discovered` or\n`scope-creep` and link it — the discovery chain is the point of the\nclassification.\n\nThe new task comes back `in_progress` and becomes the active one. Track against\nit for the rest of the work.\n\nExtra context from the user, if any: $ARGUMENTS"
|
|
51
|
+
}
|
|
52
|
+
];
|