@ezmodo/mcp-server 0.13.3 → 0.13.5

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/http.js ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HTTP entry point — the MCP tool surface over Streamable HTTP (#2599).
4
+ *
5
+ * This is what a remote connector talks to. The stdio entry point (index.js)
6
+ * is unchanged and stays the local path; both build the same server through
7
+ * lib/create-server.js, so the tool surface cannot differ between them.
8
+ *
9
+ * ── Stateless, and why ────────────────────────────────────────────────────────
10
+ * A fresh Server and transport are built per request, with
11
+ * `sessionIdGenerator: undefined`. The alternative — stateful sessions held in
12
+ * memory — cannot survive the deployment target: Cloud Run runs several
13
+ * instances with no session affinity, so a client's second request routinely
14
+ * lands on an instance that has never heard of its session and is rejected with
15
+ * a 404. Scale-to-zero would drop every session on the floor as well.
16
+ *
17
+ * Building per request is cheap here precisely because nothing in the server
18
+ * holds state: every handler is a function of its arguments plus the
19
+ * credential on the request.
20
+ *
21
+ * The cost is that the server cannot initiate messages to the client, so GET
22
+ * (the SSE upgrade) is answered 405. Today nothing is lost — this server sends
23
+ * no notifications and requests no sampling. If that changes, the fix is an
24
+ * external event store, not in-memory sessions.
25
+ *
26
+ * ── Authentication ───────────────────────────────────────────────────────────
27
+ * The credential comes from the request's Authorization header and is bound to
28
+ * that request only, via lib/request-context.js. Nothing is read from the
29
+ * environment, and no credential outlives the request that carried it.
30
+ *
31
+ * Today the bearer token is an EzModo API key. OAuth 2.1 lands in #2600/#2601;
32
+ * the 401 below already carries a WWW-Authenticate header so the discovery
33
+ * handshake has somewhere to attach.
34
+ */
35
+
36
+ import { createServer as createHttpServer } from 'node:http';
37
+ import { randomUUID } from 'node:crypto';
38
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
39
+
40
+ import { createServer } from './lib/create-server.js';
41
+ import { withRequestContext } from './lib/request-context.js';
42
+ import { initLogger, getLogger } from './lib/logger.js';
43
+ import { MCP_VERSION } from './lib/version.js';
44
+ import { CONFIG } from './config/index.js';
45
+ import { getApiUrl } from './lib/env.js';
46
+
47
+ initLogger();
48
+ const log = getLogger();
49
+
50
+ const PORT = Number(process.env.PORT || 8080);
51
+ const MCP_PATH = process.env.MCP_HTTP_PATH || '/mcp';
52
+
53
+ // OAuth discovery (#2601). MCP_PUBLIC_URL is this server's public identity —
54
+ // the "resource" in RFC 9728 terms — and must be the URL a client actually
55
+ // dials, because a resource identifier that does not match what was requested
56
+ // is how a token gets accepted for the wrong audience.
57
+ const PUBLIC_URL = (process.env.MCP_PUBLIC_URL || `http://localhost:${PORT}${MCP_PATH}`).replace(/\/+$/, '');
58
+ const KEYCLOAK_URL = (process.env.KEYCLOAK_URL || '').replace(/\/+$/, '');
59
+ const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'ezmodo';
60
+ const AUTH_SERVER = KEYCLOAK_URL ? `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}` : '';
61
+
62
+ // RFC 9728 forms the metadata URL by inserting /.well-known/... between the
63
+ // host and the resource's path — so a resource at https://host/mcp publishes
64
+ // at https://host/.well-known/oauth-protected-resource/mcp, NOT at the root.
65
+ // Getting this wrong costs nothing for a client that follows the URL in the
66
+ // 401, and everything for one that derives it instead, which the spec permits.
67
+ const WELL_KNOWN = '/.well-known/oauth-protected-resource';
68
+ const RESOURCE_PATH = new URL(PUBLIC_URL).pathname.replace(/\/+$/, '');
69
+ const METADATA_PATH = `${WELL_KNOWN}${RESOURCE_PATH}`;
70
+ const METADATA_URL = new URL(METADATA_PATH, PUBLIC_URL).toString();
71
+
72
+ // Every path a client might reasonably ask for. The spec-derived one is what
73
+ // gets advertised; the others are served because being generous here is free
74
+ // and a failed discovery is opaque to debug from the client side.
75
+ const METADATA_PATHS = new Set([METADATA_PATH, WELL_KNOWN, `${MCP_PATH}${WELL_KNOWN}`]);
76
+
77
+ // The three scopes a person sees on the consent screen. Deliberately not the
78
+ // ~33 the API enforces: a consent screen with thirty-three checkboxes is not
79
+ // informed consent. The API expands these — see
80
+ // api/internal/api/middleware/mcp_scopes.go.
81
+ const CONSENT_SCOPES = ['ezmodo:read', 'ezmodo:write', 'ezmodo:delete'];
82
+
83
+ /**
84
+ * RFC 9728 protected-resource metadata: what this resource is, and who issues
85
+ * tokens for it. A connector fetches this after a 401 to discover where to send
86
+ * the user for authorization.
87
+ */
88
+ export function protectedResourceMetadata() {
89
+ return {
90
+ resource: PUBLIC_URL,
91
+ authorization_servers: AUTH_SERVER ? [AUTH_SERVER] : [],
92
+ scopes_supported: CONSENT_SCOPES,
93
+ bearer_methods_supported: ['header'],
94
+ resource_documentation: 'https://ezmodo.com/help/connectors',
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The WWW-Authenticate challenge. `resource_metadata` is the whole point: it
100
+ * is how a client that has never seen this server finds the authorization
101
+ * server without being told out of band.
102
+ */
103
+ function authenticateChallenge(error, description) {
104
+ const parts = ['Bearer realm="ezmodo-mcp"', `resource_metadata="${METADATA_URL}"`];
105
+ if (error) parts.push(`error="${error}"`);
106
+ if (description) parts.push(`error_description="${description}"`);
107
+ return parts.join(', ');
108
+ }
109
+
110
+ /** Bearer token from the Authorization header, or null. */
111
+ export function bearerToken(headerValue) {
112
+ if (typeof headerValue !== 'string') return null;
113
+ const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
114
+ const token = match?.[1]?.trim();
115
+ return token ? token : null;
116
+ }
117
+
118
+ function sendJson(res, status, body, extraHeaders = {}) {
119
+ const payload = JSON.stringify(body);
120
+ res.writeHead(status, {
121
+ 'Content-Type': 'application/json',
122
+ 'Content-Length': Buffer.byteLength(payload),
123
+ ...extraHeaders,
124
+ });
125
+ res.end(payload);
126
+ }
127
+
128
+ /** A JSON-RPC error shaped so an MCP client can read it, not just a bare HTTP code. */
129
+ function rpcError(res, status, code, message, extraHeaders) {
130
+ sendJson(res, status, { jsonrpc: '2.0', error: { code, message }, id: null }, extraHeaders);
131
+ }
132
+
133
+ async function readBody(req, limitBytes = 8 * 1024 * 1024) {
134
+ const chunks = [];
135
+ let size = 0;
136
+ for await (const chunk of req) {
137
+ size += chunk.length;
138
+ // Bounded on purpose: this endpoint is reachable by anyone who can reach
139
+ // the service, so an unbounded read is a trivial way to exhaust memory.
140
+ if (size > limitBytes) {
141
+ const err = new Error('Request body too large');
142
+ err.statusCode = 413;
143
+ throw err;
144
+ }
145
+ chunks.push(chunk);
146
+ }
147
+ if (!chunks.length) return undefined;
148
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'));
149
+ }
150
+
151
+ async function handleMcpPost(req, res, requestId) {
152
+ const token = bearerToken(req.headers.authorization);
153
+ if (!token) {
154
+ log.warn('MCP request without credentials', { requestId });
155
+ // The WWW-Authenticate header is where OAuth discovery attaches in #2601;
156
+ // it costs nothing now and means clients already look in the right place.
157
+ return rpcError(res, 401, -32001, 'Missing or malformed Authorization header. Expected: Bearer <token>', {
158
+ 'WWW-Authenticate': authenticateChallenge('invalid_request', 'Authorization header required'),
159
+ });
160
+ }
161
+
162
+ let body;
163
+ try {
164
+ body = await readBody(req);
165
+ } catch (error) {
166
+ const status = error.statusCode === 413 ? 413 : 400;
167
+ log.warn('Unreadable MCP request body', { requestId, error: error.message });
168
+ return rpcError(res, status, -32700, `Could not parse request body: ${error.message}`);
169
+ }
170
+
171
+ // One server and transport per request — see the stateless note at the top.
172
+ // 'remote': excludes tools that operate on a local checkout, which do not
173
+ // exist here and whose git helpers shell out with caller-supplied arguments
174
+ // (#2614).
175
+ const server = createServer({ surface: 'remote' });
176
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
177
+
178
+ // Closing on response end matters: without it every request leaks a transport
179
+ // and its server, and the leak only shows up under sustained load.
180
+ res.on('close', () => {
181
+ transport.close().catch(() => {});
182
+ server.close().catch(() => {});
183
+ });
184
+
185
+ await server.connect(transport);
186
+ await withRequestContext({ apiKey: token }, () => transport.handleRequest(req, res, body));
187
+ }
188
+
189
+ const httpServer = createHttpServer(async (req, res) => {
190
+ const requestId = randomUUID();
191
+ const started = Date.now();
192
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
193
+
194
+ res.on('finish', () => {
195
+ log.info('http request', {
196
+ requestId,
197
+ method: req.method,
198
+ path: url.pathname,
199
+ status: res.statusCode,
200
+ durationMs: Date.now() - started,
201
+ });
202
+ });
203
+
204
+ try {
205
+ // Health check. Deliberately does NOT touch the EzModo API: this answers
206
+ // "is this process serving?", and a health check that fails when a
207
+ // dependency is briefly unavailable gets the container killed and makes an
208
+ // outage worse.
209
+ // Served unauthenticated and before anything else: discovery is what a
210
+ // client reads BECAUSE it has no credential yet, so requiring one would
211
+ // make the handshake unresolvable.
212
+ if (METADATA_PATHS.has(url.pathname)) {
213
+ return sendJson(res, 200, protectedResourceMetadata(), {
214
+ 'Cache-Control': 'public, max-age=3600',
215
+ });
216
+ }
217
+
218
+ if (url.pathname === '/health' || url.pathname === '/healthz') {
219
+ return sendJson(res, 200, { status: 'ok', version: MCP_VERSION, environment: CONFIG.environment });
220
+ }
221
+
222
+ if (url.pathname !== MCP_PATH) {
223
+ return rpcError(res, 404, -32601, `Not found. The MCP endpoint is ${MCP_PATH}`);
224
+ }
225
+
226
+ if (req.method === 'POST') {
227
+ return await handleMcpPost(req, res, requestId);
228
+ }
229
+
230
+ // GET is the SSE upgrade for server-initiated messages, which stateless
231
+ // mode cannot serve. Said plainly rather than by a bare 405, because
232
+ // "method not allowed" on a spec-defined method reads as a bug.
233
+ if (req.method === 'GET') {
234
+ return rpcError(res, 405, -32000,
235
+ 'This server runs stateless; server-initiated SSE streams are not supported. Use POST.',
236
+ { Allow: 'POST' });
237
+ }
238
+
239
+ return rpcError(res, 405, -32000, `${req.method} is not supported on ${MCP_PATH}`, { Allow: 'POST' });
240
+ } catch (error) {
241
+ log.error('Unhandled error serving request', {
242
+ requestId,
243
+ error: error?.message || String(error),
244
+ });
245
+ if (!res.headersSent) {
246
+ rpcError(res, 500, -32603, 'Internal server error');
247
+ } else {
248
+ res.end();
249
+ }
250
+ }
251
+ });
252
+
253
+ httpServer.listen(PORT, () => {
254
+ log.info('MCP server running over HTTP', { port: PORT, path: MCP_PATH, version: MCP_VERSION });
255
+ console.error(`✅ ezmodo MCP Server (HTTP) on :${PORT}${MCP_PATH}`);
256
+ // The RESOLVED url, not CONFIG.apiUrl: EZMODO_API_URL overrides it per call,
257
+ // so printing the build-time default tells an operator pointing this at a
258
+ // local stack the opposite of what is happening.
259
+ console.error(` Environment: ${CONFIG.environment} API URL: ${getApiUrl() || CONFIG.apiUrl}`);
260
+ console.error(` Resource: ${PUBLIC_URL}`);
261
+ console.error(` Metadata: ${METADATA_URL}`);
262
+ // Say so loudly rather than serving a metadata document with an empty
263
+ // authorization_servers array, which fails later and further away.
264
+ console.error(
265
+ AUTH_SERVER
266
+ ? ` Authorization server: ${AUTH_SERVER}`
267
+ : ' ⚠️ KEYCLOAK_URL is unset — OAuth discovery will advertise no authorization server.'
268
+ );
269
+ });
270
+
271
+ // Cloud Run sends SIGTERM before reclaiming an instance. Closing gracefully
272
+ // lets in-flight tool calls finish instead of being cut off mid-request.
273
+ for (const signal of ['SIGTERM', 'SIGINT']) {
274
+ process.on(signal, () => {
275
+ log.info('Shutting down', { signal });
276
+ httpServer.close(() => process.exit(0));
277
+ setTimeout(() => process.exit(0), 10_000).unref();
278
+ });
279
+ }
package/index.js CHANGED
@@ -7,47 +7,62 @@
7
7
  * projects, tasks, and documentation via HTTP API.
8
8
  */
9
9
 
10
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
- import {
13
- CallToolRequestSchema,
14
- ListToolsRequestSchema,
15
- ListPromptsRequestSchema,
16
- GetPromptRequestSchema,
17
- } from '@modelcontextprotocol/sdk/types.js';
18
11
 
19
12
  // Import configuration
20
13
  import { CONFIG } from './config/index.js';
21
14
 
22
- // Import tool definitions and handlers
23
- import { TOOLS } from './tools/index.js';
24
- import { HANDLERS } from './handlers/index.js';
25
-
26
- // Import prompts
27
- import { PROMPTS, getPromptContent } from './prompts/index.js';
28
-
29
- import { MCP_VERSION } from './lib/version.js';
15
+ // The tool surface, shared with the HTTP entry point (http.js).
16
+ import { createServer } from './lib/create-server.js';
30
17
  import { initLogger, getLogger } from './lib/logger.js';
31
18
  import { getApiKey } from './lib/env.js';
19
+ import { readCliCredential } from './lib/cli-credential.js';
32
20
 
33
21
  // ============================================================
34
22
  // Configuration Validation
35
23
  // ============================================================
36
24
 
37
- const apiKey = getApiKey();
38
-
39
25
  // Initialize logger early (before any logging)
40
26
  initLogger();
41
27
  const log = getLogger();
42
28
 
29
+ let apiKey = getApiKey();
30
+ let apiKeySource = 'EZMODO_API_KEY';
31
+
32
+ // Fall back to the credential `ezmodo auth login` already stored (#2611).
33
+ //
34
+ // The environment always wins: an explicit key must beat an implicit one, or
35
+ // overriding it for a single project becomes impossible to reason about. This
36
+ // only runs when there is nothing to override.
37
+ //
38
+ // Assigning to process.env rather than threading the value through is
39
+ // deliberate — lib/http-client.js re-reads getApiKey() on every request, so
40
+ // this is the one place that has to know. It is the same thing the CLI's own
41
+ // startMcpServer does before importing a handler.
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
+
43
51
  if (!apiKey) {
44
- log.error('Missing required environment variable: EZMODO_API_KEY', {
52
+ log.error('No API key: EZMODO_API_KEY is unset and no ezmodo CLI credential was found', {
45
53
  settingsUrl: CONFIG.settingsUrl,
46
54
  });
47
- console.error('ERROR: Missing required environment variable:');
48
- console.error('- EZMODO_API_KEY (or legacy ZEPHLY_API_KEY)');
49
- console.error('\nPlease set your ezmodo API key to use this MCP server.');
50
- console.error(`Generate one at: ${CONFIG.settingsUrl}`);
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}`);
51
66
  console.error('\nOptional environment variables:');
52
67
  console.error('- EZMODO_API_URL (default: production Go API URL)');
53
68
  console.error('- EZMODO_ENVIRONMENT (dev/staging/production)');
@@ -63,103 +78,25 @@ log.info('MCP server starting', {
63
78
  environment: CONFIG.environment,
64
79
  apiUrl: CONFIG.apiUrl,
65
80
  apiKeyPrefix: apiKey.substring(0, 12),
81
+ apiKeySource,
66
82
  });
67
83
  console.error('🔧 ezmodo MCP Server Configuration:');
68
84
  console.error(` Environment: ${CONFIG.environment} (build-time)`);
69
85
  console.error(` API URL: ${CONFIG.apiUrl}`);
70
- console.error(` API Key: ${apiKey.substring(0, 12)}...`);
86
+ // Name the source. A server that silently authenticates as whoever the CLI
87
+ // happens to be logged in as, with no way to tell, is worse than one that fails.
88
+ console.error(` API Key: ${apiKey.substring(0, 12)}... (from ${apiKeySource})`);
71
89
  console.error('');
72
90
 
73
91
  // ============================================================
74
- // MCP Server Setup
92
+ // Start Server (stdio)
75
93
  // ============================================================
94
+ //
95
+ // This file is the STDIO entry point. The HTTP entry point is http.js; both
96
+ // build the same server through lib/create-server.js, so the tool surface
97
+ // cannot differ between them.
76
98
 
77
- const server = new Server(
78
- {
79
- name: 'ezmodo-mcp-server',
80
- version: MCP_VERSION,
81
- },
82
- {
83
- capabilities: {
84
- tools: {},
85
- prompts: {},
86
- },
87
- }
88
- );
89
-
90
- // Register tool handlers
91
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
92
- tools: TOOLS,
93
- }));
94
-
95
- // Register tool call handler
96
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
97
- const { name, arguments: args } = request.params;
98
-
99
- // Get handler function for this tool
100
- const handler = HANDLERS[name];
101
-
102
- if (!handler) {
103
- throw new Error(`Unknown tool: ${name}`);
104
- }
105
-
106
- const start = Date.now();
107
- try {
108
- const result = await handler(args || {});
109
- log.debug('Tool call succeeded', { tool: name, durationMs: Date.now() - start });
110
- return {
111
- content: [
112
- {
113
- type: 'text',
114
- text: JSON.stringify(result, null, 2),
115
- },
116
- ],
117
- };
118
- } catch (error) {
119
- const errMsg = error.message || String(error);
120
- log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
121
- return {
122
- content: [
123
- {
124
- type: 'text',
125
- text: JSON.stringify({
126
- error: error.message || String(error),
127
- }, null, 2),
128
- },
129
- ],
130
- isError: true,
131
- };
132
- }
133
- });
134
-
135
- // Register prompt handlers
136
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
137
- prompts: PROMPTS,
138
- }));
139
-
140
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
141
- const content = getPromptContent(request.params.name);
142
-
143
- if (!content) {
144
- throw new Error(`Unknown prompt: ${request.params.name}`);
145
- }
146
-
147
- return {
148
- messages: [
149
- {
150
- role: 'user',
151
- content: {
152
- type: 'text',
153
- text: content,
154
- },
155
- },
156
- ],
157
- };
158
- });
159
-
160
- // ============================================================
161
- // Start Server
162
- // ============================================================
99
+ const server = createServer();
163
100
 
164
101
  async function main() {
165
102
  const transport = new StdioServerTransport();
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Read the API key the `ezmodo` CLI already stored, when EZMODO_API_KEY is not
3
+ * set.
4
+ *
5
+ * Why this exists: the Claude Code plugin launches this server through npx and
6
+ * passes `${EZMODO_API_KEY:-}` from the environment. A user who has run
7
+ * `ezmodo auth login` has a perfectly good key on disk and no idea they must
8
+ * also export it, so their first run dies and Claude Code reports
9
+ * `CONNECTION_CLOSED` — the server's own clear message goes to a stderr log
10
+ * nobody opens (#2611).
11
+ *
12
+ * This is a FALLBACK, never a requirement. The CLI does not have to be
13
+ * installed, nothing here throws, and an explicit EZMODO_API_KEY always wins:
14
+ * an explicit credential must beat an implicit one, or overriding the key for
15
+ * one project becomes impossible to reason about.
16
+ *
17
+ * It deliberately re-implements the CLI's read rather than importing it — this
18
+ * package is published to npm and must not depend on the CLI being present.
19
+ * The formats it reads are owned by cli/src/lib/auth-store.ts and
20
+ * cli/src/lib/user-paths.ts; if those move, this goes stale and simply stops
21
+ * finding anything, which is the safe direction to fail in.
22
+ */
23
+
24
+ import { execFileSync } from 'child_process';
25
+ import { existsSync, readFileSync } from 'fs';
26
+ import { homedir } from 'os';
27
+ import { join } from 'path';
28
+
29
+ // `.config/ezmodo` is current, `.config/zephly` the pre-rebrand name still
30
+ // present in older installs. Current wins; legacy is a read fallback only.
31
+ function configDirs() {
32
+ const home = homedir();
33
+ if (process.platform === 'win32') {
34
+ const base = process.env.APPDATA || home;
35
+ return [join(base, 'ezmodo'), join(base, 'zephly')];
36
+ }
37
+ return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
38
+ }
39
+
40
+ /** The Linux/Windows path: a 0600 JSON file written by `ezmodo auth login`. */
41
+ function fromCredentialsFile() {
42
+ for (const dir of configDirs()) {
43
+ const path = join(dir, 'credentials');
44
+ if (!existsSync(path)) continue;
45
+ try {
46
+ const key = JSON.parse(readFileSync(path, 'utf-8'))?.apiKey;
47
+ if (typeof key === 'string' && key.trim()) return key.trim();
48
+ } catch {
49
+ // Unreadable or malformed — try the next location, then give up.
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * The macOS path: the CLI stores in the login Keychain via the `security`
57
+ * binary, so there is no file to read.
58
+ *
59
+ * Bounded by a timeout on purpose. Keychain items carry an ACL, and a read the
60
+ * ACL does not allow can raise a GUI prompt — which, from a server started
61
+ * headlessly by an editor, would be a worse failure than the one this whole
62
+ * module exists to fix. The timeout means the worst case is a dialog that
63
+ * disappears within two seconds and a fall through to "no key found", i.e.
64
+ * exactly today's behaviour.
65
+ */
66
+ function fromMacKeychain() {
67
+ if (process.platform !== 'darwin') return null;
68
+ for (const service of ['ezmodo-cli', 'zephly-cli']) {
69
+ try {
70
+ const key = execFileSync(
71
+ 'security',
72
+ ['find-generic-password', '-s', service, '-a', 'api-key', '-w'],
73
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }
74
+ ).trim();
75
+ if (key) return key;
76
+ } catch {
77
+ // Not stored under this name, no `security` binary, denied, or timed
78
+ // out. All of them mean the same thing here: try the next, then stop.
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * The CLI's stored key, or null. Never throws.
86
+ *
87
+ * Returns `{ key, source }` so the caller can tell the user WHERE the
88
+ * credential came from. A server that silently authenticates as someone the
89
+ * user did not choose is worse than one that fails.
90
+ */
91
+ export function readCliCredential() {
92
+ try {
93
+ const fileKey = fromCredentialsFile();
94
+ if (fileKey) return { key: fileKey, source: 'ezmodo CLI credentials file' };
95
+
96
+ const keychainKey = fromMacKeychain();
97
+ if (keychainKey) return { key: keychainKey, source: 'macOS Keychain (ezmodo CLI)' };
98
+ } catch {
99
+ // Belt and braces. Nothing above should throw, and if something does, a
100
+ // missing fallback must not take down the server.
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Builds a configured MCP `Server` — tools, prompts and their handlers.
3
+ *
4
+ * Extracted from index.js so stdio and HTTP share ONE definition of the tool
5
+ * surface (#2599). Two entry points that each register their own handlers is
6
+ * how a tool ends up working on one transport and not the other, and the gap
7
+ * is invisible until a user reports it.
8
+ *
9
+ * A fresh Server per call, deliberately. The HTTP transport runs stateless and
10
+ * builds one per request; that is only safe because nothing here holds state
11
+ * between calls — every handler is a function of its arguments plus the
12
+ * credential in the request context.
13
+ */
14
+
15
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
+ import {
17
+ CallToolRequestSchema,
18
+ ListToolsRequestSchema,
19
+ ListPromptsRequestSchema,
20
+ GetPromptRequestSchema,
21
+ } from '@modelcontextprotocol/sdk/types.js';
22
+
23
+ import { TOOLS } from '../tools/index.js';
24
+ import { HANDLERS } from '../handlers/index.js';
25
+ import { PROMPTS, getPromptContent } from '../prompts/index.js';
26
+ import { MCP_VERSION } from './version.js';
27
+ import { getLogger } from './logger.js';
28
+ import { isRemoteSafe } from './remote-tools.js';
29
+
30
+ /**
31
+ * @param {object} [options]
32
+ * @param {'local'|'remote'} [options.surface] Which tool surface to serve.
33
+ * 'local' (the default) is the full set, for stdio on a user's machine.
34
+ * 'remote' excludes tools that operate on the local filesystem or git — see
35
+ * lib/remote-tools.js for why that is an allowlist and not a denylist.
36
+ */
37
+ export function createServer({ surface = 'local' } = {}) {
38
+ const log = getLogger();
39
+
40
+ // Filtered ONCE here rather than at each call site, so listing and dispatch
41
+ // cannot disagree. They must agree: filtering only tools/list would leave
42
+ // every excluded handler dispatchable by a client that guesses the name,
43
+ // which is the failure this whole module exists to prevent.
44
+ const tools = surface === 'remote' ? TOOLS.filter((tool) => isRemoteSafe(tool.name)) : TOOLS;
45
+ const available = new Set(tools.map((tool) => tool.name));
46
+
47
+ const server = new Server(
48
+ { name: 'ezmodo-mcp-server', version: MCP_VERSION },
49
+ { capabilities: { tools: {}, prompts: {} } }
50
+ );
51
+
52
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
53
+
54
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
55
+ const { name, arguments: args } = request.params;
56
+
57
+ // Checked before the handler lookup: a tool excluded from this surface is
58
+ // refused even though HANDLERS still contains it.
59
+ if (!available.has(name)) {
60
+ const handler = HANDLERS[name];
61
+ if (handler) {
62
+ log.warn('Refused a tool not served on this surface', { tool: name, surface });
63
+ throw new Error(
64
+ `Tool "${name}" is not available over this connection. It operates on a local ` +
65
+ 'checkout and is served only by the local (stdio) MCP server.'
66
+ );
67
+ }
68
+ throw new Error(`Unknown tool: ${name}`);
69
+ }
70
+
71
+ const handler = HANDLERS[name];
72
+ if (!handler) {
73
+ throw new Error(`Unknown tool: ${name}`);
74
+ }
75
+
76
+ const start = Date.now();
77
+ try {
78
+ const result = await handler(args || {});
79
+ log.debug('Tool call succeeded', { tool: name, durationMs: Date.now() - start });
80
+ return {
81
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
82
+ };
83
+ } catch (error) {
84
+ const errMsg = error.message || String(error);
85
+ log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
86
+ // Returned as content rather than thrown: a tool that fails is a result
87
+ // the model can read and act on, not a transport error.
88
+ return {
89
+ content: [{ type: 'text', text: JSON.stringify({ error: errMsg }, null, 2) }],
90
+ isError: true,
91
+ };
92
+ }
93
+ });
94
+
95
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
96
+
97
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
98
+ const content = getPromptContent(request.params.name);
99
+ if (!content) {
100
+ throw new Error(`Unknown prompt: ${request.params.name}`);
101
+ }
102
+ return {
103
+ messages: [{ role: 'user', content: { type: 'text', text: content } }],
104
+ };
105
+ });
106
+
107
+ return server;
108
+ }
@@ -9,7 +9,12 @@ import { ENDPOINT_MAP } from '../config/endpoint-map.js';
9
9
  import { CONFIG } from '../config/index.js';
10
10
  import { MCP_VERSION } from './version.js';
11
11
  import { getLogger } from './logger.js';
12
- import { getApiKey, getApiUrl } from './env.js';
12
+ import { getApiUrl } from './env.js';
13
+ // Not getApiKey() directly: over HTTP one process serves many callers, so the
14
+ // credential belongs to the request in flight rather than the process (#2599).
15
+ // Over stdio there is no request context and this resolves to the environment,
16
+ // exactly as before.
17
+ import { resolveApiKey } from './request-context.js';
13
18
 
14
19
  // API base URL is resolved per-request (see callZephlyAPI): getApiUrl() honors
15
20
  // the EZMODO_API_URL / ZEPHLY_API_URL override; CONFIG.apiUrl is the build-time
@@ -78,7 +83,7 @@ export async function callZephlyAPI(endpoint, data) {
78
83
  let body = null;
79
84
 
80
85
  const headers = {
81
- 'Authorization': `Bearer ${getApiKey()}`,
86
+ 'Authorization': `Bearer ${resolveApiKey()}`,
82
87
  'Content-Type': 'application/json',
83
88
  'User-Agent': `ezmodo-mcp-server/${MCP_VERSION}`,
84
89
  'X-MCP-API-Version': 'v1',
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Which tools may be served over the REMOTE transport (#2614).
3
+ *
4
+ * The HTTP entry point and the stdio entry point deliberately build the same
5
+ * server (lib/create-server.js) so their tool surfaces cannot drift. That is
6
+ * right for almost everything — and wrong for the handful of tools that are
7
+ * not network operations at all.
8
+ *
9
+ * `detect_git_repository`, `manage_worktree`, `rebuild_manifest` and friends
10
+ * read and write the LOCAL machine: the working directory, `.ezmodo/`, the git
11
+ * repository. Over stdio that is the whole point — the server runs inside the
12
+ * user's checkout, at their request, as them. On a hosted server there is no
13
+ * checkout, so at best they fail confusingly.
14
+ *
15
+ * At worst they are dangerous. lib/git-helpers.js `execGit` passes its command
16
+ * to `execSync` — a shell — and callers interpolate tool arguments into it:
17
+ *
18
+ * execGit(`git branch ${branchName} ${baseBranch}`, repoPath)
19
+ *
20
+ * On a user's own machine the blast radius is their own shell, which is why
21
+ * this has been tolerable. Reachable over the internet it is arbitrary command
22
+ * execution in the container. So these tools are not hardened for remote use —
23
+ * they are not served remotely at all, because they have no meaning there and
24
+ * hardening would leave a shell-executing surface exposed for no benefit.
25
+ *
26
+ * ── The list is an ALLOWLIST, deliberately ───────────────────────────────────
27
+ *
28
+ * A denylist would mean every tool added from now on is exposed remotely by
29
+ * default, and the mistake would be invisible: the tool simply works, until one
30
+ * of them turns out to touch the filesystem. Listing what is safe means a new
31
+ * tool is refused remotely until someone decides otherwise, and
32
+ * remote-tools.test.js fails loudly when a tool is unclassified rather than
33
+ * letting it through.
34
+ *
35
+ * Note some tools do local work as a SIDE EFFECT and are still fine here:
36
+ * manage_task writes `.ezmodo/active-session.json`, and lib/active-session.js
37
+ * already skips that when no config directory exists — which is exactly the
38
+ * case in a container. Likewise get_context and resolve_concepts read a local
39
+ * manifest when there is one and fall back to the API when there is not.
40
+ */
41
+
42
+ /** Tools that operate on the local machine and are never served remotely. */
43
+ export const LOCAL_ONLY_TOOLS = Object.freeze([
44
+ 'detect_git_repository',
45
+ 'get_current_project_context',
46
+ 'initialize_project_context',
47
+ 'list_project_worktrees',
48
+ 'manage_worktree',
49
+ 'rebuild_manifest',
50
+ ]);
51
+
52
+ /** Tools safe to serve over the remote transport. */
53
+ export const REMOTE_SAFE_TOOLS = Object.freeze([
54
+ 'accept_agent_suggestion',
55
+ 'configure_agent',
56
+ 'create_tasks',
57
+ 'delete_attachment',
58
+ 'estimate_task',
59
+ 'evaluate_feature_flag',
60
+ 'get_access',
61
+ 'get_ai_insights',
62
+ 'get_attachment_url',
63
+ 'get_catalog',
64
+ 'get_catalog_diff',
65
+ 'get_context',
66
+ 'get_decision',
67
+ 'get_design',
68
+ 'get_design_system',
69
+ 'get_document',
70
+ 'get_document_template',
71
+ 'get_epic',
72
+ 'get_feature',
73
+ 'get_feature_flag',
74
+ 'get_goal',
75
+ 'get_graph',
76
+ 'get_manifest_schema',
77
+ 'get_milestone',
78
+ 'get_org_areas',
79
+ 'get_organization',
80
+ 'get_project',
81
+ 'get_project_changes',
82
+ 'get_project_story',
83
+ 'get_task',
84
+ 'get_testing_summary',
85
+ 'infer_dependencies',
86
+ 'list_agent_suggestions',
87
+ 'list_attachments',
88
+ 'list_catalog_items',
89
+ 'list_catalogs',
90
+ 'list_components',
91
+ 'list_designs',
92
+ 'list_epics',
93
+ 'list_facts',
94
+ 'list_feature_flags',
95
+ 'list_folders',
96
+ 'list_links',
97
+ 'list_notifications',
98
+ 'list_org_documents',
99
+ 'list_repositories',
100
+ 'list_tags',
101
+ 'list_test_cases',
102
+ 'list_test_suites',
103
+ 'list_todos',
104
+ 'list_watched',
105
+ 'manage_access',
106
+ 'manage_catalog',
107
+ 'manage_component',
108
+ 'manage_decision',
109
+ 'manage_design',
110
+ 'manage_document',
111
+ 'manage_document_template',
112
+ 'manage_environment',
113
+ 'manage_epic',
114
+ 'manage_fact',
115
+ 'manage_feature',
116
+ 'manage_feature_flag',
117
+ 'manage_folder',
118
+ 'manage_goal',
119
+ 'manage_link',
120
+ 'manage_milestone',
121
+ 'manage_project',
122
+ 'manage_pull_request',
123
+ 'manage_recurring_task',
124
+ 'manage_tag',
125
+ 'manage_task',
126
+ 'manage_team',
127
+ 'manage_test_case',
128
+ 'manage_test_suite',
129
+ 'manage_todo',
130
+ 'manage_watch',
131
+ 'manage_work_template',
132
+ 'preview_links',
133
+ 'reject_agent_suggestion',
134
+ 'report_untracked_work',
135
+ 'resolve_concepts',
136
+ 'resolve_link_suggestions',
137
+ 'resolve_links',
138
+ 'run_agent_now',
139
+ 'search_epics',
140
+ 'search_features',
141
+ 'search_tasks',
142
+ 'update_manifest_entries',
143
+ 'validate_manifest',
144
+ ]);
145
+
146
+ const remoteSafe = new Set(REMOTE_SAFE_TOOLS);
147
+
148
+ /** Whether a tool may be served over the remote transport. */
149
+ export function isRemoteSafe(toolName) {
150
+ return remoteSafe.has(toolName);
151
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Per-request credential context.
3
+ *
4
+ * Over stdio the server belongs to one user for its whole life, so the API key
5
+ * can live in the environment and `lib/http-client.js` can read it whenever it
6
+ * likes. Over HTTP that assumption is gone: one process serves many callers,
7
+ * each with their own credential, and requests interleave (#2599).
8
+ *
9
+ * AsyncLocalStorage is what makes that a five-line change instead of a
10
+ * signature change through every handler. The transport runs each request
11
+ * inside `withRequestContext`, and `resolveApiKey()` picks up whatever that
12
+ * request carried — through every await, without any handler knowing it exists.
13
+ *
14
+ * The environment remains the fallback, and that is what keeps stdio working
15
+ * untouched: no context, no behaviour change.
16
+ *
17
+ * The safety property worth stating: a leaked context is a request answered
18
+ * with SOMEONE ELSE'S credential. So the store is only ever written by
19
+ * withRequestContext, is never mutated in place, and nothing exported here can
20
+ * set it for longer than one callback.
21
+ */
22
+
23
+ import { AsyncLocalStorage } from 'async_hooks';
24
+ import { getApiKey } from './env.js';
25
+
26
+ const storage = new AsyncLocalStorage();
27
+
28
+ /**
29
+ * Run `fn` with `context` bound to it and everything it awaits.
30
+ *
31
+ * @param {{ apiKey?: string }} context
32
+ * @param {() => Promise<T>|T} fn
33
+ * @returns {Promise<T>|T}
34
+ * @template T
35
+ */
36
+ export function withRequestContext(context, fn) {
37
+ return storage.run(Object.freeze({ ...context }), fn);
38
+ }
39
+
40
+ /** The current request's context, or undefined outside one. */
41
+ export function getRequestContext() {
42
+ return storage.getStore();
43
+ }
44
+
45
+ /**
46
+ * The credential for the request in flight.
47
+ *
48
+ * Request context wins over the environment. Over HTTP that is the whole
49
+ * point; over stdio there is no context and this is exactly getApiKey().
50
+ */
51
+ export function resolveApiKey() {
52
+ return storage.getStore()?.apiKey || getApiKey();
53
+ }
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  *
8
8
  * Update this when bumping the version in package.json.
9
9
  */
10
- export const MCP_VERSION = '0.13.3';
10
+ export const MCP_VERSION = '0.13.5';
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "MCP server for ezmodo - AI-first project management",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "ezmodo-mcp-server": "./index.js",
9
- "zephly-mcp-server": "./index.js"
9
+ "zephly-mcp-server": "./index.js",
10
+ "ezmodo-mcp-server-http": "./http.js"
10
11
  },
11
12
  "scripts": {
12
13
  "dev": "BUILD_ENV=development node index.js",
13
14
  "start": "node index.js",
15
+ "start:http": "node http.js",
16
+ "dev:http": "BUILD_ENV=development node http.js",
14
17
  "build:production": "BUILD_ENV=production node --input-type=module -e 'console.log(\"Production build configured\")'",
15
18
  "build:staging": "BUILD_ENV=staging node --input-type=module -e 'console.log(\"Staging build configured\")'",
16
19
  "build:development": "BUILD_ENV=development node --input-type=module -e 'console.log(\"Development build configured\")'",
@@ -34,6 +37,7 @@
34
37
  },
35
38
  "files": [
36
39
  "index.js",
40
+ "http.js",
37
41
  "config/",
38
42
  "handlers/",
39
43
  "lib/",
@@ -41,10 +45,10 @@
41
45
  "tools/",
42
46
  "README.md"
43
47
  ],
44
- "repository": {
45
- "type": "git",
46
- "url": "https://github.com/EasyModeOnly/ezmodo.git",
47
- "directory": "mcp-server"
48
+ "homepage": "https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp",
49
+ "bugs": {
50
+ "url": "https://ezmodo.com/support",
51
+ "email": "help@ezmodo.com"
48
52
  },
49
53
  "dependencies": {
50
54
  "@modelcontextprotocol/sdk": "^1.30.0",