@ezmodo/mcp-server 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/endpoint-map.js +6 -0
- package/handlers/epics.js +73 -1
- package/handlers/index.js +2 -0
- package/http.js +52 -22
- package/index.js +7 -5
- package/lib/create-server.js +5 -11
- package/lib/http-diagnostics.js +53 -0
- package/lib/logger.js +23 -6
- package/lib/remote-tools.js +2 -0
- package/lib/version.js +1 -1
- package/package.json +5 -3
- package/tools/epics.js +76 -2
package/config/endpoint-map.js
CHANGED
|
@@ -33,6 +33,12 @@ export const ENDPOINT_MAP = {
|
|
|
33
33
|
'mcpGetEpicPlan': { route: 'mcp/v1/epics/plan', method: 'GET' },
|
|
34
34
|
'mcpUpdateEpicPlan': { route: 'mcp/v1/epics/plan', method: 'PUT' },
|
|
35
35
|
'mcpListEpicComments': { route: 'mcp/v1/epics/comments', method: 'GET' },
|
|
36
|
+
// Catch me up (E-259 #2746).
|
|
37
|
+
'mcpGetEpicActivity': { route: 'mcp/v1/epics/activity', method: 'GET' },
|
|
38
|
+
// Plan proposals (E-259 #2745): suggest a change to a plan you cannot save.
|
|
39
|
+
'mcpListPlanProposals': { route: 'mcp/v1/epics/proposals', method: 'GET' },
|
|
40
|
+
'mcpProposePlanChange': { route: 'mcp/v1/epics/proposals', method: 'POST' },
|
|
41
|
+
'mcpReviewPlanProposal': { route: 'mcp/v1/epics/proposals/review', method: 'POST' },
|
|
36
42
|
'mcpAddEpicComment': { route: 'mcp/v1/epics/comments', method: 'POST' },
|
|
37
43
|
// E-237 #2382: the epic is the fifth consumer of the grounding engine.
|
|
38
44
|
'mcpGenerateEpicHowItWorks': { route: 'mcp/v1/epics/generate-how-it-works', method: 'POST' },
|
package/handlers/epics.js
CHANGED
|
@@ -224,7 +224,18 @@ export async function updateEpicPlan(args) {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
/**
|
|
227
|
-
*
|
|
227
|
+
* Catch me up (E-259 #2746): what changed on an epic since the caller last
|
|
228
|
+
* looked. Marks it caught up unless markSeen is false.
|
|
229
|
+
*/
|
|
230
|
+
export async function getEpicActivity(args) {
|
|
231
|
+
const params = { epicId: args.epicId };
|
|
232
|
+
if (args.since) params.since = args.since;
|
|
233
|
+
if (args.markSeen === false) params.markSeen = 'false';
|
|
234
|
+
return callZephlyAPI('mcpGetEpicActivity', params);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* An epic's discussion, oldest first (E-259).
|
|
228
239
|
*/
|
|
229
240
|
export async function listEpicComments(args) {
|
|
230
241
|
return callZephlyAPI('mcpListEpicComments', args);
|
|
@@ -236,3 +247,64 @@ export async function listEpicComments(args) {
|
|
|
236
247
|
export async function addEpicComment(args) {
|
|
237
248
|
return callZephlyAPI('mcpAddEpicComment', args);
|
|
238
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Suggest a change to an epic's plan, and answer suggestions (E-259 #2745).
|
|
253
|
+
*
|
|
254
|
+
* One tool with actions rather than five tool names: the tool list is read by
|
|
255
|
+
* every agent on every call, so each new name costs everyone.
|
|
256
|
+
*/
|
|
257
|
+
export async function managePlanProposal(args = {}) {
|
|
258
|
+
const { action } = args;
|
|
259
|
+
|
|
260
|
+
switch (action) {
|
|
261
|
+
case 'propose': {
|
|
262
|
+
if (!args.epicId) throw new Error('epicId is required to suggest a change');
|
|
263
|
+
if (!args.plan && !args.ops) {
|
|
264
|
+
throw new Error('Send the plan you want (or the individual changes) to suggest a change');
|
|
265
|
+
}
|
|
266
|
+
return callZephlyAPI('mcpProposePlanChange', {
|
|
267
|
+
epicId: args.epicId,
|
|
268
|
+
plan: args.plan,
|
|
269
|
+
ops: args.ops,
|
|
270
|
+
title: args.title,
|
|
271
|
+
rationale: args.rationale,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'list': {
|
|
276
|
+
if (!args.epicId) throw new Error('epicId is required to list proposals');
|
|
277
|
+
const params = { epicId: args.epicId };
|
|
278
|
+
if (args.status) params.status = args.status;
|
|
279
|
+
return callZephlyAPI('mcpListPlanProposals', params);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'get': {
|
|
283
|
+
if (!args.proposalId) throw new Error('proposalId is required');
|
|
284
|
+
return callZephlyAPI('mcpListPlanProposals', { proposalId: args.proposalId });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case 'review': {
|
|
288
|
+
if (!args.proposalId) throw new Error('proposalId is required to answer a proposal');
|
|
289
|
+
if (!args.accept?.length && !args.reject?.length) {
|
|
290
|
+
throw new Error('Say which changes you are taking (accept) and which you are not (reject)');
|
|
291
|
+
}
|
|
292
|
+
return callZephlyAPI('mcpReviewPlanProposal', {
|
|
293
|
+
proposalId: args.proposalId,
|
|
294
|
+
accept: args.accept || [],
|
|
295
|
+
reject: args.reject || [],
|
|
296
|
+
note: args.note,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'withdraw': {
|
|
301
|
+
if (!args.proposalId) throw new Error('proposalId is required to take back a proposal');
|
|
302
|
+
return callZephlyAPI('mcpReviewPlanProposal', { proposalId: args.proposalId, withdraw: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Unknown action "${action}". Use propose, list, get, review or withdraw.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
package/handlers/index.js
CHANGED
|
@@ -62,6 +62,8 @@ export const HANDLERS = {
|
|
|
62
62
|
get_epic_plan: epicHandlers.getEpicPlan,
|
|
63
63
|
update_epic_plan: epicHandlers.updateEpicPlan,
|
|
64
64
|
list_epic_comments: epicHandlers.listEpicComments,
|
|
65
|
+
get_epic_activity: epicHandlers.getEpicActivity,
|
|
66
|
+
manage_plan_proposal: epicHandlers.managePlanProposal,
|
|
65
67
|
add_epic_comment: epicHandlers.addEpicComment,
|
|
66
68
|
|
|
67
69
|
// Milestones
|
package/http.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* lib/create-server.js, so the tool surface cannot differ between them.
|
|
8
8
|
*
|
|
9
9
|
* ── Stateless, and why ────────────────────────────────────────────────────────
|
|
10
|
-
* A fresh Server
|
|
11
|
-
*
|
|
10
|
+
* A fresh Server is built per request: SDK v2's createMcpHandler calls the
|
|
11
|
+
* factory each time, and serves 2025-era clients through the same stateless
|
|
12
|
+
* idiom v1 used (`sessionIdGenerator: undefined`, a transport per request).
|
|
13
|
+
* 2026-07-28 is stateless by design. The alternative — stateful sessions held in
|
|
12
14
|
* memory — cannot survive the deployment target: Cloud Run runs several
|
|
13
15
|
* instances with no session affinity, so a client's second request routinely
|
|
14
16
|
* lands on an instance that has never heard of its session and is rejected with
|
|
@@ -35,7 +37,9 @@
|
|
|
35
37
|
|
|
36
38
|
import { createServer as createHttpServer } from 'node:http';
|
|
37
39
|
import { randomUUID } from 'node:crypto';
|
|
38
|
-
import {
|
|
40
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
41
|
+
import { createMcpHandler } from '@modelcontextprotocol/server';
|
|
42
|
+
import { toNodeHandler } from '@modelcontextprotocol/node';
|
|
39
43
|
|
|
40
44
|
import { createServer } from './lib/create-server.js';
|
|
41
45
|
import { withRequestContext } from './lib/request-context.js';
|
|
@@ -43,12 +47,15 @@ import { initLogger, getLogger } from './lib/logger.js';
|
|
|
43
47
|
import { MCP_VERSION } from './lib/version.js';
|
|
44
48
|
import { CONFIG } from './config/index.js';
|
|
45
49
|
import { getApiUrl } from './lib/env.js';
|
|
50
|
+
import { healthPaths, describeRejectedRequest } from './lib/http-diagnostics.js';
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
// Structured: this process's stderr is read by Cloud Logging, not a person.
|
|
53
|
+
initLogger(false, undefined, { structured: true });
|
|
48
54
|
const log = getLogger();
|
|
49
55
|
|
|
50
56
|
const PORT = Number(process.env.PORT || 8080);
|
|
51
57
|
const MCP_PATH = process.env.MCP_HTTP_PATH || '/mcp';
|
|
58
|
+
const HEALTH_PATHS = healthPaths(MCP_PATH);
|
|
52
59
|
|
|
53
60
|
// OAuth discovery (#2601). MCP_PUBLIC_URL is this server's public identity —
|
|
54
61
|
// the "resource" in RFC 9728 terms — and must be the URL a client actually
|
|
@@ -120,6 +127,43 @@ export function bearerToken(headerValue) {
|
|
|
120
127
|
return token ? token : null;
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
// The request being served, for the handler-level onerror below. The SDK
|
|
131
|
+
// reports why it refused a request only through that callback, which is set
|
|
132
|
+
// once for the whole process; this is how a report finds its way back to the
|
|
133
|
+
// request it is about. Same mechanism as the credential in request-context.js.
|
|
134
|
+
const inFlight = new AsyncLocalStorage();
|
|
135
|
+
|
|
136
|
+
// One handler for the process; the factory builds a fresh server for each
|
|
137
|
+
// request. It serves protocol revision 2026-07-28 (the `server/discover`
|
|
138
|
+
// Claude opens every connection with, which SDK v1 answered with a 400) and,
|
|
139
|
+
// by default (legacy: 'stateless'), 2025-era clients the same stateless way
|
|
140
|
+
// the v1 transport did. See the stateless note at the top.
|
|
141
|
+
//
|
|
142
|
+
// 'remote': excludes tools that operate on a local checkout, which do not
|
|
143
|
+
// exist here and whose git helpers shell out with caller-supplied arguments
|
|
144
|
+
// (#2614).
|
|
145
|
+
const mcpHandler = createMcpHandler(() => createServer({ surface: 'remote' }), {
|
|
146
|
+
onerror: (error) => {
|
|
147
|
+
const current = inFlight.getStore();
|
|
148
|
+
log.warn('MCP transport rejected request', {
|
|
149
|
+
requestId: current?.requestId,
|
|
150
|
+
error: error?.message || String(error),
|
|
151
|
+
...(current ? describeRejectedRequest(current.req, current.body) : {}),
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const serveMcp = toNodeHandler(mcpHandler, {
|
|
157
|
+
// The adapter itself failed (converting the request, or the handler threw)
|
|
158
|
+
// and is about to answer 500.
|
|
159
|
+
onerror: (error) => {
|
|
160
|
+
log.error('MCP handler failed', {
|
|
161
|
+
requestId: inFlight.getStore()?.requestId,
|
|
162
|
+
error: error?.message || String(error),
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
123
167
|
function sendJson(res, status, body, extraHeaders = {}) {
|
|
124
168
|
const payload = JSON.stringify(body);
|
|
125
169
|
res.writeHead(status, {
|
|
@@ -173,22 +217,8 @@ async function handleMcpPost(req, res, requestId) {
|
|
|
173
217
|
return rpcError(res, status, -32700, `Could not parse request body: ${error.message}`);
|
|
174
218
|
}
|
|
175
219
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// exist here and whose git helpers shell out with caller-supplied arguments
|
|
179
|
-
// (#2614).
|
|
180
|
-
const server = createServer({ surface: 'remote' });
|
|
181
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
182
|
-
|
|
183
|
-
// Closing on response end matters: without it every request leaks a transport
|
|
184
|
-
// and its server, and the leak only shows up under sustained load.
|
|
185
|
-
res.on('close', () => {
|
|
186
|
-
transport.close().catch(() => {});
|
|
187
|
-
server.close().catch(() => {});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
await server.connect(transport);
|
|
191
|
-
await withRequestContext({ apiKey: token }, () => transport.handleRequest(req, res, body));
|
|
220
|
+
await withRequestContext({ apiKey: token }, () =>
|
|
221
|
+
inFlight.run({ requestId, req, body }, () => serveMcp(req, res, body)));
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
const httpServer = createHttpServer(async (req, res) => {
|
|
@@ -220,7 +250,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
220
250
|
});
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
if (
|
|
253
|
+
if (HEALTH_PATHS.has(url.pathname)) {
|
|
224
254
|
return sendJson(res, 200, { status: 'ok', version: MCP_VERSION, environment: CONFIG.environment });
|
|
225
255
|
}
|
|
226
256
|
|
|
@@ -278,7 +308,7 @@ httpServer.listen(PORT, () => {
|
|
|
278
308
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
279
309
|
process.on(signal, () => {
|
|
280
310
|
log.info('Shutting down', { signal });
|
|
281
|
-
httpServer.close(() => process.exit(0));
|
|
311
|
+
httpServer.close(() => mcpHandler.close().finally(() => process.exit(0)));
|
|
282
312
|
setTimeout(() => process.exit(0), 10_000).unref();
|
|
283
313
|
});
|
|
284
314
|
}
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* projects, tasks, and documentation via HTTP API.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
11
11
|
|
|
12
12
|
// Import configuration
|
|
13
13
|
import { CONFIG } from './config/index.js';
|
|
@@ -65,11 +65,13 @@ console.error('');
|
|
|
65
65
|
// build the same server through lib/create-server.js, so the tool surface
|
|
66
66
|
// cannot differ between them.
|
|
67
67
|
|
|
68
|
-
const server = createServer();
|
|
69
|
-
|
|
70
68
|
async function main() {
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
// serveStdio, not server.connect(new StdioServerTransport()): a Server wired
|
|
70
|
+
// straight to the transport speaks only the 2025-era protocol (SDK v2
|
|
71
|
+
// migration guide). serveStdio lets the client's opening message pick the
|
|
72
|
+
// era, 2026-07-28 or 2025, and pins one instance from the factory for the
|
|
73
|
+
// life of the connection.
|
|
74
|
+
serveStdio(() => createServer());
|
|
73
75
|
log.info('MCP server running on stdio');
|
|
74
76
|
console.error('✅ ezmodo MCP Server running on stdio');
|
|
75
77
|
}
|
package/lib/create-server.js
CHANGED
|
@@ -12,13 +12,7 @@
|
|
|
12
12
|
* credential in the request context.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { Server } from '@modelcontextprotocol/
|
|
16
|
-
import {
|
|
17
|
-
CallToolRequestSchema,
|
|
18
|
-
ListToolsRequestSchema,
|
|
19
|
-
ListPromptsRequestSchema,
|
|
20
|
-
GetPromptRequestSchema,
|
|
21
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
22
16
|
|
|
23
17
|
import { TOOLS } from '../tools/index.js';
|
|
24
18
|
import { HANDLERS } from '../handlers/index.js';
|
|
@@ -105,9 +99,9 @@ export function createServer({ surface = 'local', startSignIn = defaultStartSign
|
|
|
105
99
|
}
|
|
106
100
|
);
|
|
107
101
|
|
|
108
|
-
server.setRequestHandler(
|
|
102
|
+
server.setRequestHandler('tools/list', async () => ({ tools }));
|
|
109
103
|
|
|
110
|
-
server.setRequestHandler(
|
|
104
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
111
105
|
const { name, arguments: args } = request.params;
|
|
112
106
|
|
|
113
107
|
// Checked before the handler lookup: a tool excluded from this surface is
|
|
@@ -214,11 +208,11 @@ export function createServer({ surface = 'local', startSignIn = defaultStartSign
|
|
|
214
208
|
|
|
215
209
|
// Filtered by surface exactly as tools are, and for the same reason: `submit`
|
|
216
210
|
// reads git SHAs and links commits, which a hosted server cannot do.
|
|
217
|
-
server.setRequestHandler(
|
|
211
|
+
server.setRequestHandler('prompts/list', async () => ({
|
|
218
212
|
prompts: listPrompts(surface),
|
|
219
213
|
}));
|
|
220
214
|
|
|
221
|
-
server.setRequestHandler(
|
|
215
|
+
server.setRequestHandler('prompts/get', async (request) => {
|
|
222
216
|
const content = getPromptContent(request.params.name, request.params.arguments, surface);
|
|
223
217
|
if (!content) {
|
|
224
218
|
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small pure helpers for the HTTP transport (http.js), kept here because
|
|
3
|
+
* http.js starts listening the moment it is imported and so cannot be tested.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The paths that answer a health check.
|
|
8
|
+
*
|
|
9
|
+
* `/health` and `/healthz` are what Cloud Run's probes dial, straight at the
|
|
10
|
+
* container. `${mcpPath}/health` is the only one reachable from OUTSIDE: the
|
|
11
|
+
* load balancer forwards just `/mcp` and `/mcp/*` here, with the path
|
|
12
|
+
* unchanged, and `/health` at the domain root belongs to the web app. The
|
|
13
|
+
* uptime check has always probed `/mcp/health`, and until this was added it
|
|
14
|
+
* got a 404 every minute.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} mcpPath
|
|
17
|
+
* @returns {Set<string>}
|
|
18
|
+
*/
|
|
19
|
+
export function healthPaths(mcpPath) {
|
|
20
|
+
return new Set(['/health', '/healthz', `${mcpPath}/health`]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What to log about a request the MCP transport refused.
|
|
25
|
+
*
|
|
26
|
+
* The SDK answers a malformed or unsupported request with a 400 and reports
|
|
27
|
+
* why only through `transport.onerror`. Without this, production logged a 400
|
|
28
|
+
* at the start of every Claude Desktop session and nothing else, so nobody
|
|
29
|
+
* could tell which request it was or what the SDK disliked about it.
|
|
30
|
+
*
|
|
31
|
+
* Only the shape is kept: JSON-RPC methods and the protocol headers. Never the
|
|
32
|
+
* Authorization header, and never params, which can carry user content.
|
|
33
|
+
*
|
|
34
|
+
* @param {import('node:http').IncomingMessage} req
|
|
35
|
+
* @param {unknown} body parsed JSON body, possibly a batch array
|
|
36
|
+
*/
|
|
37
|
+
export function describeRejectedRequest(req, body) {
|
|
38
|
+
const messages = Array.isArray(body) ? body : body ? [body] : [];
|
|
39
|
+
const methods = messages.map((m) => (m && typeof m === 'object' && typeof m.method === 'string'
|
|
40
|
+
? m.method
|
|
41
|
+
: '(no method)'));
|
|
42
|
+
const initialize = messages.find((m) => m?.method === 'initialize');
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
methods,
|
|
46
|
+
batch: Array.isArray(body),
|
|
47
|
+
protocolVersionHeader: req.headers['mcp-protocol-version'] ?? null,
|
|
48
|
+
sessionIdHeader: req.headers['mcp-session-id'] ? 'present' : null,
|
|
49
|
+
initializeProtocolVersion: initialize?.params?.protocolVersion ?? null,
|
|
50
|
+
accept: req.headers.accept ?? null,
|
|
51
|
+
contentType: req.headers['content-type'] ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/lib/logger.js
CHANGED
|
@@ -19,6 +19,11 @@ import { homedir } from 'os';
|
|
|
19
19
|
const RETENTION_DAYS = 7;
|
|
20
20
|
const DATE_PATTERN = /^ezmodo-(\d{4}-\d{2}-\d{2})\.log$/;
|
|
21
21
|
|
|
22
|
+
// Cloud Logging's LogSeverity names. `warn` must become WARNING: an
|
|
23
|
+
// unrecognised severity is stored as DEFAULT, which a severity>=WARNING filter
|
|
24
|
+
// never matches.
|
|
25
|
+
const CLOUD_SEVERITY = { debug: 'DEBUG', info: 'INFO', warn: 'WARNING', error: 'ERROR' };
|
|
26
|
+
|
|
22
27
|
function getDateString() {
|
|
23
28
|
return new Date().toISOString().slice(0, 10);
|
|
24
29
|
}
|
|
@@ -32,14 +37,28 @@ function getLogFilePath(logsDir) {
|
|
|
32
37
|
* @param {string} [options.source='mcp']
|
|
33
38
|
* @param {boolean} [options.verbose=false]
|
|
34
39
|
* @param {string} [options.logsDir]
|
|
40
|
+
* @param {boolean} [options.structured=false] write stderr lines as one JSON
|
|
41
|
+
* object each — see stderrLine
|
|
35
42
|
*/
|
|
36
43
|
export function createLogger(options = {}) {
|
|
37
44
|
const {
|
|
38
45
|
source = 'mcp',
|
|
39
46
|
verbose = false,
|
|
40
47
|
logsDir = join(homedir(), '.ezmodo', 'logs'),
|
|
48
|
+
structured = false,
|
|
41
49
|
} = options;
|
|
42
50
|
|
|
51
|
+
// Over stdio a person reads stderr in a terminal, so it stays a short line.
|
|
52
|
+
// Over HTTP it is read by Cloud Logging, which parses a JSON line into
|
|
53
|
+
// jsonPayload and maps `severity`; and the log FILE is on an ephemeral
|
|
54
|
+
// container disk nobody ever sees. Plain lines there meant the connector's
|
|
55
|
+
// warnings reached production as a bare message, with every field
|
|
56
|
+
// (requestId, error, what was rejected) lost.
|
|
57
|
+
function stderrLine(level, msg, data) {
|
|
58
|
+
if (!structured) return `[${source}] ${msg}`;
|
|
59
|
+
return JSON.stringify({ severity: CLOUD_SEVERITY[level], message: msg, source, ...data });
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// Ensure logs directory exists (fire-and-forget)
|
|
44
63
|
let dirReady = mkdir(logsDir, { recursive: true }).catch(() => {});
|
|
45
64
|
|
|
@@ -59,10 +78,8 @@ export function createLogger(options = {}) {
|
|
|
59
78
|
}).catch(() => {});
|
|
60
79
|
|
|
61
80
|
// stderr routing: warn/error always go to stderr; debug/info only if verbose
|
|
62
|
-
if (level === 'warn' || level === 'error') {
|
|
63
|
-
console.error(
|
|
64
|
-
} else if (verbose) {
|
|
65
|
-
console.error(`[${source}] ${msg}`);
|
|
81
|
+
if (level === 'warn' || level === 'error' || verbose) {
|
|
82
|
+
console.error(stderrLine(level, msg, data));
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
85
|
|
|
@@ -101,8 +118,8 @@ export function createLogger(options = {}) {
|
|
|
101
118
|
// Singleton
|
|
102
119
|
let _logger = null;
|
|
103
120
|
|
|
104
|
-
export function initLogger(verbose = false, logsDir) {
|
|
105
|
-
_logger = createLogger({ source: 'mcp', verbose, logsDir });
|
|
121
|
+
export function initLogger(verbose = false, logsDir, { structured = false } = {}) {
|
|
122
|
+
_logger = createLogger({ source: 'mcp', verbose, logsDir, structured });
|
|
106
123
|
_logger.cleanup();
|
|
107
124
|
return _logger;
|
|
108
125
|
}
|
package/lib/remote-tools.js
CHANGED
|
@@ -76,6 +76,7 @@ export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
|
76
76
|
'get_document',
|
|
77
77
|
'get_document_template',
|
|
78
78
|
'get_epic',
|
|
79
|
+
'get_epic_activity',
|
|
79
80
|
'get_epic_plan',
|
|
80
81
|
'get_feature',
|
|
81
82
|
'get_feature_flag',
|
|
@@ -144,6 +145,7 @@ export const REMOTE_SAFE_TOOLS = Object.freeze([
|
|
|
144
145
|
'resolve_link_suggestions',
|
|
145
146
|
'resolve_links',
|
|
146
147
|
'resolve_unmapped',
|
|
148
|
+
'manage_plan_proposal',
|
|
147
149
|
'run_agent_now',
|
|
148
150
|
'search_epics',
|
|
149
151
|
'search_features',
|
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.19.1",
|
|
4
4
|
"description": "MCP server for ezmodo - AI-first project management",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
"email": "help@ezmodo.com"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@modelcontextprotocol/
|
|
63
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
64
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
65
|
+
"hono": "^4.13.8",
|
|
64
66
|
"node-fetch": "^3.3.2"
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
|
@@ -70,6 +72,6 @@
|
|
|
70
72
|
"jest": "^30.5.1"
|
|
71
73
|
},
|
|
72
74
|
"engines": {
|
|
73
|
-
"node": ">=
|
|
75
|
+
"node": ">=20.0.0"
|
|
74
76
|
}
|
|
75
77
|
}
|
package/tools/epics.js
CHANGED
|
@@ -311,8 +311,9 @@ export const EPIC_TOOLS = [
|
|
|
311
311
|
'current plan and what changed — apply your change to THAT plan and save again with its revision. ' +
|
|
312
312
|
'Never resend your old copy: that erases their work. ' +
|
|
313
313
|
'Keep plans simple and readable by anyone: a plain title and one line on why for each task. ' +
|
|
314
|
-
'Only the epic\'s owner, its creator or an organization admin can save the plan
|
|
315
|
-
'
|
|
314
|
+
'Only the epic\'s owner, its creator or an organization admin can save the plan. If you are ' +
|
|
315
|
+
'refused, do not give up: send the SAME plan to manage_plan_proposal action "propose" and the ' +
|
|
316
|
+
'owner can accept your changes one at a time.',
|
|
316
317
|
inputSchema: {
|
|
317
318
|
type: 'object',
|
|
318
319
|
properties: {
|
|
@@ -385,6 +386,33 @@ export const EPIC_TOOLS = [
|
|
|
385
386
|
required: ['epicId', 'baseRevision', 'plan'],
|
|
386
387
|
},
|
|
387
388
|
},
|
|
389
|
+
{
|
|
390
|
+
name: 'get_epic_activity',
|
|
391
|
+
description: 'Catch me up on an epic (E-259): what changed since YOU last looked. Returns `summary`, ' +
|
|
392
|
+
'plain sentences you can relay to your person as-is, most important first: decisions waiting on ' +
|
|
393
|
+
'their view, comments that mention or reply to them, decisions made, new plan versions (who ' +
|
|
394
|
+
'changed what, and which AI did it for them), and tasks added, started, finished or blocked. ' +
|
|
395
|
+
'The details are alongside. Their own changes are left out. Call it when you start or resume ' +
|
|
396
|
+
'work on an epic other people also work on, and before changing its plan. By default this also ' +
|
|
397
|
+
'marks the epic as caught up, so the next call shows only newer changes; pass markSeen:false to ' +
|
|
398
|
+
'look without that.',
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
epicId: { type: 'string', description: 'The epic ID (required)' },
|
|
403
|
+
since: {
|
|
404
|
+
type: 'string',
|
|
405
|
+
description: 'Show changes since this time (RFC 3339, e.g. 2026-09-19T14:00:00Z) instead of ' +
|
|
406
|
+
'since the last catch-up. The first catch-up on an epic covers the last 7 days.',
|
|
407
|
+
},
|
|
408
|
+
markSeen: {
|
|
409
|
+
type: 'boolean',
|
|
410
|
+
description: 'Mark the epic as caught up after answering (default true)',
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
required: ['epicId'],
|
|
414
|
+
},
|
|
415
|
+
},
|
|
388
416
|
{
|
|
389
417
|
name: 'list_epic_comments',
|
|
390
418
|
description: 'Read an epic\'s discussion (E-259), oldest first. Each comment says who wrote it and, ' +
|
|
@@ -416,4 +444,50 @@ export const EPIC_TOOLS = [
|
|
|
416
444
|
required: ['epicId', 'content'],
|
|
417
445
|
},
|
|
418
446
|
},
|
|
447
|
+
{
|
|
448
|
+
name: 'manage_plan_proposal',
|
|
449
|
+
description: 'Suggest a change to an epic\'s plan when you cannot save it yourself, and answer ' +
|
|
450
|
+
'suggestions on plans you own (E-259).\n\n' +
|
|
451
|
+
'propose: send the plan you WANT, exactly as you would to update_epic_plan. The server works out ' +
|
|
452
|
+
'what you changed and lists it as separate changes, each with a plain sentence, so the owner can ' +
|
|
453
|
+
'take some and leave others. Nothing changes until they do.\n' +
|
|
454
|
+
'list: what is waiting on an epic. Open ones come first, and each change that no longer fits the ' +
|
|
455
|
+
'current plan is flagged with the reason.\n' +
|
|
456
|
+
'review (owner, creator or org admin only): `accept` and `reject` name changes by their op id. ' +
|
|
457
|
+
'A change you name in neither is left for later and the proposal stays open. A change whose task ' +
|
|
458
|
+
'someone has since removed is reported back as stale rather than quietly reapplied.\n' +
|
|
459
|
+
'withdraw: take back a proposal you made.',
|
|
460
|
+
inputSchema: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
properties: {
|
|
463
|
+
action: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
enum: ['propose', 'list', 'get', 'review', 'withdraw'],
|
|
466
|
+
description: 'What to do',
|
|
467
|
+
},
|
|
468
|
+
epicId: { type: 'string', description: 'The epic (required for propose and list)' },
|
|
469
|
+
proposalId: { type: 'string', description: 'The proposal (required for get, review and withdraw)' },
|
|
470
|
+
plan: {
|
|
471
|
+
type: 'object',
|
|
472
|
+
description: 'propose: the whole plan you want, same shape as update_epic_plan. Keep each ' +
|
|
473
|
+
'planned task\'s id so your change is matched to the right task.',
|
|
474
|
+
},
|
|
475
|
+
title: { type: 'string', description: 'propose: a short name for the proposal' },
|
|
476
|
+
rationale: { type: 'string', description: 'propose: why, in a sentence or two, in plain language' },
|
|
477
|
+
accept: {
|
|
478
|
+
type: 'array',
|
|
479
|
+
items: { type: 'string' },
|
|
480
|
+
description: 'review: op ids of the changes you are taking',
|
|
481
|
+
},
|
|
482
|
+
reject: {
|
|
483
|
+
type: 'array',
|
|
484
|
+
items: { type: 'string' },
|
|
485
|
+
description: 'review: op ids of the changes you are turning down',
|
|
486
|
+
},
|
|
487
|
+
note: { type: 'string', description: 'review: what you want to say back, in your own words' },
|
|
488
|
+
status: { type: 'string', description: 'list: only proposals in this state (default: all)' },
|
|
489
|
+
},
|
|
490
|
+
required: ['action'],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
419
493
|
];
|