tododdle-mcp 3.8.0 → 3.9.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/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { z } from 'zod';
5
+ import { ToDoddleApiError } from './api-client.js';
5
6
  import { prepareUploadSource } from './upload-source.js';
6
7
  import { TODODDLE_WORKFLOW_GUIDANCE } from './workflow-guidance.js';
7
8
  const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -229,6 +230,85 @@ const assessmentEvidenceSchema = z
229
230
  });
230
231
  }
231
232
  });
233
+ /**
234
+ * Turns a ToDoddle API failure into a tool error the agent can act on. The SDK
235
+ * default keeps only the message, which dropped the scope an owner must grant.
236
+ */
237
+ export function apiErrorResult(error) {
238
+ const missing = error.scopes?.missingScopes ?? [];
239
+ const hint = error.code === 'scope_required' && missing.length
240
+ ? `Ask a workspace owner or admin to enable ${missing.join(', ')} for this Agent Connection in Workspace Settings > Agent Connections, then retry.`
241
+ : error.code === 'scope_required'
242
+ ? 'Ask a workspace owner or admin to check the scopes on this Agent Connection in Workspace Settings > Agent Connections.'
243
+ : runEnvelopeRequired(error.details)
244
+ ? 'This ticket has an active Agent Run for this connection. Pass the runId and projectId of that run with this call, then retry.'
245
+ : undefined;
246
+ const body = {
247
+ error: error.code,
248
+ status: error.status,
249
+ message: error.message,
250
+ ...(missing.length ? { missingScopes: missing } : {}),
251
+ ...(error.scopes?.requiredScopes ? { requiredScopes: error.scopes.requiredScopes } : {}),
252
+ ...(hint ? { hint } : {}),
253
+ ...(error.details !== undefined ? { details: error.details } : {}),
254
+ };
255
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(body) }] };
256
+ }
257
+ function runEnvelopeRequired(details) {
258
+ return (typeof details === 'object' &&
259
+ details !== null &&
260
+ details.reason === 'RUN_ENVELOPE_REQUIRED');
261
+ }
262
+ const LINK_KEYS = new Set(['uiUrl', 'href']);
263
+ /**
264
+ * The API returns web links as paths such as /dashboard/project/x. Left
265
+ * relative, a client guesses the host and often picks www.tododdle.com, which
266
+ * is the marketing site. Turn them into absolute links on the app origin.
267
+ * In-app embed paths (/api/documents/...) are left alone.
268
+ */
269
+ export function absolutizeLinks(value, appOrigin) {
270
+ if (Array.isArray(value))
271
+ return value.map((item) => absolutizeLinks(item, appOrigin));
272
+ if (!value || typeof value !== 'object')
273
+ return value;
274
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
275
+ key,
276
+ LINK_KEYS.has(key) &&
277
+ typeof item === 'string' &&
278
+ item.startsWith('/') &&
279
+ !item.startsWith('//')
280
+ ? new URL(item, appOrigin).toString()
281
+ : absolutizeLinks(item, appOrigin),
282
+ ]));
283
+ }
284
+ function withAbsoluteLinks(result, appOrigin) {
285
+ const typed = result;
286
+ if (!appOrigin || !typed?.structuredContent)
287
+ return result;
288
+ const structuredContent = absolutizeLinks(typed.structuredContent, appOrigin);
289
+ return {
290
+ ...typed,
291
+ structuredContent,
292
+ content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
293
+ };
294
+ }
295
+ /**
296
+ * Wraps every tool so API failures keep their code, scopes, and hint, and
297
+ * web links in results are absolute.
298
+ */
299
+ function withApiErrorResults(server, appOrigin) {
300
+ const register = server.registerTool.bind(server);
301
+ server.registerTool = (name, config, callback) => register(name, config, (async (...args) => {
302
+ try {
303
+ return withAbsoluteLinks(await callback(...args), appOrigin);
304
+ }
305
+ catch (error) {
306
+ if (error instanceof ToDoddleApiError)
307
+ return apiErrorResult(error);
308
+ throw error;
309
+ }
310
+ }));
311
+ }
232
312
  function toolResult(value) {
233
313
  return {
234
314
  // Keep text content for clients that do not consume structuredContent, but
@@ -338,8 +418,35 @@ const completeUploadInputSchema = z.object({
338
418
  error: z.string().max(1000).optional(),
339
419
  idempotencyKey: z.string().min(8),
340
420
  });
421
+ const EMBEDDABLE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
422
+ /** Escapes a file name for use as Markdown image alt text. */
423
+ function markdownAlt(value) {
424
+ return value.replace(/[\\[\]]/g, '\\$&');
425
+ }
426
+ /**
427
+ * Adds what an agent needs next: `embedMarkdown` to show an image inline in a
428
+ * Note or Context, and a `notice` when the stored type differs. ToDoddle stores
429
+ * SVG, HTML, and other active content as an opaque file so it never runs in a
430
+ * browser; an SVG previews and embeds through a PNG rendering instead.
431
+ */
432
+ function withUploadHints(result, requestedType, fileName) {
433
+ const entity = asRecord(result.entity);
434
+ const storedType = entity?.mimeType;
435
+ const id = typeof entity?.id === 'string' ? entity.id : undefined;
436
+ const isSvg = /\.svg$/i.test(fileName);
437
+ const hints = {};
438
+ if (id && typeof storedType === 'string' && (EMBEDDABLE_IMAGE_TYPES.has(storedType) || isSvg)) {
439
+ hints.embedMarkdown = `![${markdownAlt(fileName)}](/api/documents/${id}/${isSvg ? 'thumbnail' : 'embed'})`;
440
+ }
441
+ if (typeof storedType === 'string' && storedType !== requestedType) {
442
+ hints.notice = isSvg
443
+ ? `Stored as ${storedType}: ToDoddle keeps SVG files as opaque downloads for safety. Previews and embeds use a PNG rendering of it, so embedMarkdown still shows the image.`
444
+ : `Stored as ${storedType}, not ${requestedType}. ToDoddle stores HTML and other active content as an opaque file for safety. It downloads normally but does not preview inline.`;
445
+ }
446
+ return { ...result, ...hints };
447
+ }
341
448
  async function uploadDocument(api, options, input) {
342
- const prepared = await prepareUploadSource(input, options.uploadRoots, options.maxUploadBytes, options.managedUploadRoot);
449
+ const prepared = await prepareUploadSource(input, options.maxUploadBytes, options.managedUploadRoot);
343
450
  let session = null;
344
451
  let bytesUploaded = false;
345
452
  let uploadConfirmed = false;
@@ -357,7 +464,7 @@ async function uploadDocument(api, options, input) {
357
464
  bytesUploaded = true;
358
465
  const result = await api.post(`/api/external/projects/${input.projectId}/documents/${session.documentId}/finalize`, { success: true, taskId: input.taskId }, `${idempotencyKey}-finalize`);
359
466
  uploadConfirmed = true;
360
- return result;
467
+ return withUploadHints(result, prepared.contentType, prepared.fileName);
361
468
  }
362
469
  catch (error) {
363
470
  if (session && !bytesUploaded) {
@@ -426,11 +533,12 @@ const securityEventTypes = [
426
533
  ];
427
534
  export function createToDoddleMcpServer(api, serverOptions = {}) {
428
535
  const options = {
429
- uploadRoots: serverOptions.uploadRoots ?? [],
430
536
  managedUploadRoot: serverOptions.managedUploadRoot,
537
+ appOrigin: serverOptions.appOrigin,
431
538
  maxUploadBytes: serverOptions.maxUploadBytes ?? 1024 * 1024 * 1024,
432
539
  };
433
540
  const server = new McpServer({ name: 'tododdle', version: packageVersion.version }, { instructions: TODODDLE_WORKFLOW_GUIDANCE });
541
+ withApiErrorResults(server, options.appOrigin);
434
542
  server.registerTool('list_projects', {
435
543
  description: 'List projects accessible through this Agent Connection.',
436
544
  inputSchema: z.object({
@@ -735,7 +843,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
735
843
  }, async ({ projectId, planId, sectionId, expectedUpdatedAt }) => toolResult(await api.put(`/api/external/projects/${projectId}/plans/${planId}/sections/${sectionId}`, { archived, expectedUpdatedAt })));
736
844
  }
737
845
  server.registerTool('list_releases', {
738
- description: 'List bounded release summaries in one project. Filter by status or a version and title search. Needs the `releases:read` scope.',
846
+ description: 'List bounded release summaries in one project. Filter by status or a version and title search. The result has total (releases that match) and archivedHidden (archived releases left out; set includeArchived to see them), so an empty list is not ambiguous. Needs the `releases:read` scope.',
739
847
  inputSchema: z.object({
740
848
  projectId: z.string().min(1),
741
849
  search: z.string().trim().max(100).optional(),
@@ -765,8 +873,18 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
765
873
  openWorldHint: false,
766
874
  },
767
875
  }, async ({ projectId, releaseId, taskId, runId }) => toolResult(await api.get(`/api/external/projects/${projectId}/releases/${releaseId}`, undefined, projectRunContext(runId, projectId, taskId, 'READ_PROJECT_DATA'))));
876
+ server.registerTool('get_release_metadata_templates', {
877
+ description: 'Get the release metadata templates (App Store, Google Play). Each field has the key to use in release metadata, the store character limit (maxLength), the format (text or url), and a hint. Needs the `releases:read` scope.',
878
+ inputSchema: z.object({}),
879
+ annotations: {
880
+ readOnlyHint: true,
881
+ destructiveHint: false,
882
+ idempotentHint: true,
883
+ openWorldHint: false,
884
+ },
885
+ }, async () => toolResult(await api.get('/api/external/release-metadata-templates')));
768
886
  server.registerTool('create_release', {
769
- description: 'Create a PENDING release in one project. Needs the `releases:write` scope and project owner or admin access. Publishing or abandoning a release needs request_release_status_change and human approval.',
887
+ description: 'Create a PENDING release in one project. Needs the `releases:write` scope and project owner or admin access. For store metadata, use the field keys from get_release_metadata_templates (for example "What\'s New" or "Promotional text"). The response has warnings when a value is over a store limit or a URL field is not a link; the release still saves. Publishing or abandoning a release needs request_release_status_change and human approval.',
770
888
  inputSchema: z.object({
771
889
  projectId: z.string().min(1),
772
890
  version: z.string().trim().min(1).max(100),
@@ -782,7 +900,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
782
900
  },
783
901
  }, async ({ projectId, idempotencyKey, taskId, runId, ...body }) => toolResult(await api.post(`/api/external/projects/${projectId}/releases`, body, idempotencyKey, projectRunContext(runId, projectId, taskId, 'UPDATE_INTERNAL_WORKFLOW'))));
784
902
  server.registerTool('update_release', {
785
- description: 'Update release details using expectedRevision from the latest read. This tool cannot change status or archive state; use request_release_status_change for those. Archived releases cannot be edited.',
903
+ description: 'Update release details using expectedRevision from the latest read. The response has warnings when a metadata value breaks a store limit from get_release_metadata_templates. This tool cannot change status or archive state; use request_release_status_change for those. Archived releases cannot be edited.',
786
904
  inputSchema: z.object({
787
905
  projectId: z.string().min(1),
788
906
  releaseId: releaseIdSchema,
@@ -1177,7 +1295,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1177
1295
  },
1178
1296
  }, async ({ projectId, noteId }) => toolResult(await api.get(`/api/external/notes/${noteId}`, { projectId })));
1179
1297
  server.registerTool('create_note', {
1180
- description: 'Create a workspace or project Note. Supply projectId for a project Note.',
1298
+ description: 'Create a workspace or project Note. Supply projectId for a project Note. To show an uploaded image inline, paste the embedMarkdown returned by upload_project_document or complete_upload, for example ![Map](/api/documents/<documentId>/embed); it shows only to people who can open that document.',
1181
1299
  inputSchema: z.object({
1182
1300
  projectId: z.string().min(1).optional(),
1183
1301
  title: z.string().min(1).max(500),
@@ -1193,7 +1311,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1193
1311
  },
1194
1312
  }, async ({ idempotencyKey, ...body }) => toolResult(await api.post('/api/external/notes', body, idempotencyKey)));
1195
1313
  server.registerTool('update_note', {
1196
- description: 'Update a workspace or project Note using optimistic concurrency.',
1314
+ description: 'Update a workspace or project Note using optimistic concurrency. To show an uploaded image inline, paste the embedMarkdown returned by upload_project_document or complete_upload, for example ![Map](/api/documents/<documentId>/embed); it shows only to people who can open that document.',
1197
1315
  inputSchema: z.object({
1198
1316
  projectId: z.string().min(1).optional(),
1199
1317
  noteId: z.string().min(1),
@@ -1263,9 +1381,16 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1263
1381
  idempotentHint: true,
1264
1382
  openWorldHint: false,
1265
1383
  },
1266
- }, async ({ projectId, documentId, idempotencyKey, ...body }) => toolResult(await api.post(`/api/external/projects/${projectId}/documents/${documentId}/finalize`, body, idempotencyKey)));
1384
+ }, async ({ projectId, documentId, idempotencyKey, ...body }) => {
1385
+ const result = await api.post(`/api/external/projects/${projectId}/documents/${documentId}/finalize`, body, idempotencyKey);
1386
+ // The remote client sent the bytes, so the stored type is the only type to compare.
1387
+ const entity = asRecord(result.entity);
1388
+ const fileName = typeof entity?.fileName === 'string' ? entity.fileName : '';
1389
+ const storedType = typeof entity?.mimeType === 'string' ? entity.mimeType : '';
1390
+ return toolResult(body.success === false ? result : withUploadHints(result, storedType, fileName));
1391
+ });
1267
1392
  server.registerTool('upload_project_document', {
1268
- description: 'Upload an approved local file or HTTPS URL to a project document library. Local paths must be regular files in the system temporary directory (or an optional approved upload root) and owned by the current user.',
1393
+ description: 'Upload an approved local file or HTTPS URL to a project document library. Local paths must be regular files in the system temporary directory, owned by the current user; copy a file there first if needed. SVG and HTML are stored as opaque files that download but do not preview.',
1269
1394
  inputSchema: uploadInputSchema,
1270
1395
  annotations: {
1271
1396
  readOnlyHint: false,
@@ -1275,7 +1400,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1275
1400
  },
1276
1401
  }, async (input) => toolResult(await uploadDocument(api, options, input)));
1277
1402
  server.registerTool('attach_file_to_ticket', {
1278
- description: 'Upload a local file or HTTPS URL and attach it to a ticket in the same project. Local paths must be regular files in the system temporary directory (or an optional approved upload root) and owned by the current user.',
1403
+ description: 'Upload a local file or HTTPS URL and attach it to a ticket in the same project. Local paths must be regular files in the system temporary directory, owned by the current user; copy a file there first if needed. SVG and HTML are stored as opaque files that download but do not preview.',
1279
1404
  inputSchema: taskUploadInputSchema,
1280
1405
  annotations: {
1281
1406
  readOnlyHint: false,
@@ -1394,14 +1519,14 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1394
1519
  },
1395
1520
  }, async ({ taskId }) => toolResult(await api.delete(`/api/external/focus/${taskId}`, {})));
1396
1521
  server.registerTool('search_project', {
1397
- description: 'Find compact ticket, board, lane, Note, Context, and document matches within one known project. Use this for discovery before narrow get tools instead of scanning every board or lane.',
1522
+ description: 'Find compact ticket, board, lane, Note, Context, document, and release matches within one known project. Release matches need the `releases:read` scope and match the version, title, or build. Use this for discovery before narrow get tools instead of scanning every board or lane. Each result has relevance (0 to 1; 0.8 or more is an exact or phrase title match) and matchedTerms. Matching uses titles only; an empty result means no title matched well.',
1398
1523
  inputSchema: z.object({
1399
1524
  projectId: z.string().min(1),
1400
1525
  query: z.string().trim().min(2).max(200),
1401
1526
  types: z
1402
- .array(z.enum(['TICKET', 'BOARD', 'LANE', 'NOTE', 'CONTEXT', 'DOCUMENT']))
1527
+ .array(z.enum(['TICKET', 'BOARD', 'LANE', 'NOTE', 'CONTEXT', 'DOCUMENT', 'RELEASE']))
1403
1528
  .min(1)
1404
- .max(6)
1529
+ .max(7)
1405
1530
  .optional(),
1406
1531
  limit: z.number().int().min(1).max(50).default(20),
1407
1532
  }),
@@ -1756,6 +1881,49 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1756
1881
  openWorldHint: false,
1757
1882
  },
1758
1883
  }, async ({ taskId, runId, projectId, ...body }) => toolResult(await api.post(`/api/external/tasks/${taskId}/agent-runs/${runId}`, body, undefined, runContext(runId, projectId, taskId, 'RECORD_EVIDENCE'))));
1884
+ server.registerTool('log_completed_work', {
1885
+ description: 'Record finished ad-hoc work in one call, for quick requests done in conversation without claiming a ticket first. ' +
1886
+ 'Pass taskId for an existing ticket, or ticket (planId, sectionId, title) to create one. ToDoddle claims it, sets the status, and records a finished Agent Run with your outcome and evidence (commits, tests, files, links), so it shows in run history like claimed work. ' +
1887
+ 'status defaults to COMPLETE. A ticket another person created goes to REVIEW instead; the response warns you, and you should then call create_review_request. ' +
1888
+ 'Use claim_ticket and finish_agent_run for planned work. Reuse the same idempotencyKey to retry safely.',
1889
+ inputSchema: z.object({
1890
+ projectId: z.string().min(1),
1891
+ taskId: z.string().min(1).optional(),
1892
+ ticket: z
1893
+ .object({
1894
+ planId: z.string().min(1),
1895
+ sectionId: z.string().min(1),
1896
+ title: z.string().trim().min(1).max(500),
1897
+ description: z.string().trim().max(50_000).optional(),
1898
+ kind: taskKindSchema.optional(),
1899
+ priority: prioritySchema.optional(),
1900
+ })
1901
+ .optional(),
1902
+ runId: z.string().min(1).max(200).optional(),
1903
+ outcome: z.string().trim().min(1).max(2000),
1904
+ evidence: z
1905
+ .array(z.object({
1906
+ type: agentRunEvidenceTypeSchema,
1907
+ reference: z.string().trim().min(1).max(2000),
1908
+ label: z.string().trim().min(1).max(200).optional(),
1909
+ }))
1910
+ .max(25)
1911
+ .default([]),
1912
+ status: z.enum(['COMPLETE', 'REVIEW']).default('COMPLETE'),
1913
+ idempotencyKey: z.string().trim().min(8).max(180),
1914
+ }),
1915
+ annotations: {
1916
+ readOnlyHint: false,
1917
+ destructiveHint: false,
1918
+ idempotentHint: true,
1919
+ openWorldHint: false,
1920
+ },
1921
+ }, async ({ projectId, ...body }) => {
1922
+ if (Boolean(body.taskId) === Boolean(body.ticket)) {
1923
+ throw new Error('Send taskId for an existing ticket or ticket to create one, not both.');
1924
+ }
1925
+ return toolResult(await api.post(`/api/external/projects/${projectId}/completed-work`, body));
1926
+ });
1759
1927
  server.registerTool('get_supervisor_state', {
1760
1928
  description: 'Get compact, permission-filtered task state for an external decision or supervision provider. The response contains evidence and policy hints, but grants no authority to change work state.',
1761
1929
  inputSchema: z.object({
@@ -2228,7 +2396,10 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2228
2396
  },
2229
2397
  }, async ({ projectId, artifactId, idempotencyKey, ...body }) => toolResult(await api.post(`/api/external/projects/${projectId}/artifacts/${artifactId}/relations`, body, idempotencyKey)));
2230
2398
  server.registerTool('update_project_artifact', {
2231
- description: 'Update artifact Markdown, its current short summary, or metadata using optimistic concurrency. Editing approved content returns it to draft.',
2399
+ description: 'Update artifact Markdown, its current short summary, or metadata using optimistic concurrency. Editing approved content returns it to draft. ' +
2400
+ 'To change part of a long document, send contentEdits instead of content: replace_section (by heading path, e.g. "Release Plan" or "Plan > Risks"; replaces the text under that heading and keeps its subsections), append_section (optionally inside a parent heading), or find_replace (exact text; expectedCount defaults to 1). ' +
2401
+ 'Pass baseVersion (the version you read) so an edit still applies when someone else changed a different section; the server rejects it if your section changed. ' +
2402
+ 'The response is a slim summary (id, version, updatedAt, changedFields) unless verbose is true. Embed an uploaded image with the embedMarkdown returned by the upload tools.',
2232
2403
  inputSchema: z.object({
2233
2404
  projectId: z.string().min(1),
2234
2405
  artifactId: z.string().min(1),
@@ -2236,10 +2407,36 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2236
2407
  title: z.string().min(1).optional(),
2237
2408
  summary: z.string().trim().max(500).nullable().optional(),
2238
2409
  content: z.string().optional(),
2410
+ contentEdits: z
2411
+ .array(z.discriminatedUnion('op', [
2412
+ z.object({
2413
+ op: z.literal('replace_section'),
2414
+ heading: z.string().min(1).max(500),
2415
+ content: z.string(),
2416
+ }),
2417
+ z.object({
2418
+ op: z.literal('append_section'),
2419
+ heading: z.string().min(1).max(200),
2420
+ content: z.string(),
2421
+ parent: z.string().min(1).max(500).optional(),
2422
+ level: z.number().int().min(1).max(6).optional(),
2423
+ }),
2424
+ z.object({
2425
+ op: z.literal('find_replace'),
2426
+ find: z.string().min(1),
2427
+ replace: z.string(),
2428
+ expectedCount: z.number().int().min(1).max(1000).optional(),
2429
+ }),
2430
+ ]))
2431
+ .min(1)
2432
+ .max(20)
2433
+ .optional(),
2434
+ baseVersion: z.number().int().min(1).optional(),
2239
2435
  parentArtifactId: z.string().min(1).nullable().optional(),
2240
2436
  ownerId: z.string().optional(),
2241
2437
  metadata: z.record(z.unknown()).optional(),
2242
2438
  changeNote: z.string().optional(),
2439
+ verbose: z.boolean().default(false),
2243
2440
  }),
2244
2441
  annotations: {
2245
2442
  readOnlyHint: false,
@@ -2247,7 +2444,15 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2247
2444
  idempotentHint: true,
2248
2445
  openWorldHint: false,
2249
2446
  },
2250
- }, async ({ projectId, artifactId, ...body }) => toolResult(await api.patch(`/api/external/projects/${projectId}/artifacts/${artifactId}`, body)));
2447
+ }, async ({ projectId, artifactId, verbose, ...body }) => {
2448
+ const result = await api.patch(`/api/external/projects/${projectId}/artifacts/${artifactId}${verbose ? '' : '?detail=summary'}`, body);
2449
+ // An older ToDoddle server strips contentEdits it does not know and reports success.
2450
+ const changedFields = result.changedFields;
2451
+ if (body.contentEdits && Array.isArray(changedFields) && !changedFields.includes('content')) {
2452
+ throw new Error('This ToDoddle server does not support contentEdits yet. Send the full content instead.');
2453
+ }
2454
+ return toolResult(result);
2455
+ });
2251
2456
  server.registerTool('transition_project_artifact', {
2252
2457
  description: 'Submit, approve, supersede, archive, or return a project artifact to draft.',
2253
2458
  inputSchema: z.object({