@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.
@@ -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
- * Read an epic's discussion (E-259).
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 and transport are built per request, with
11
- * `sessionIdGenerator: undefined`. The alternative stateful sessions held in
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 { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
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
- initLogger();
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
- // One server and transport per request — see the stateless note at the top.
177
- // 'remote': excludes tools that operate on a local checkout, which do not
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 (url.pathname === '/health' || url.pathname === '/healthz') {
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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
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
- const transport = new StdioServerTransport();
72
- await server.connect(transport);
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
  }
@@ -12,13 +12,7 @@
12
12
  * credential in the request context.
13
13
  */
14
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';
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(ListToolsRequestSchema, async () => ({ tools }));
102
+ server.setRequestHandler('tools/list', async () => ({ tools }));
109
103
 
110
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
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(ListPromptsRequestSchema, async () => ({
211
+ server.setRequestHandler('prompts/list', async () => ({
218
212
  prompts: listPrompts(surface),
219
213
  }));
220
214
 
221
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
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(`[${source}] ${msg}`);
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
  }
@@ -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
@@ -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.18.0';
10
+ export const MCP_VERSION = '0.19.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.18.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/sdk": "^1.30.0",
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": ">=18.0.0"
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; anyone else ' +
315
- 'suggests the change with add_epic_comment or asks it as a decision to make (manage_decision).',
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
  ];