tododdle-mcp 3.8.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,78 @@ 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
+ : undefined;
244
+ const body = {
245
+ error: error.code,
246
+ status: error.status,
247
+ message: error.message,
248
+ ...(missing.length ? { missingScopes: missing } : {}),
249
+ ...(error.scopes?.requiredScopes ? { requiredScopes: error.scopes.requiredScopes } : {}),
250
+ ...(hint ? { hint } : {}),
251
+ ...(error.details !== undefined ? { details: error.details } : {}),
252
+ };
253
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(body) }] };
254
+ }
255
+ const LINK_KEYS = new Set(['uiUrl', 'href']);
256
+ /**
257
+ * The API returns web links as paths such as /dashboard/project/x. Left
258
+ * relative, a client guesses the host and often picks www.tododdle.com, which
259
+ * is the marketing site. Turn them into absolute links on the app origin.
260
+ * In-app embed paths (/api/documents/...) are left alone.
261
+ */
262
+ export function absolutizeLinks(value, appOrigin) {
263
+ if (Array.isArray(value))
264
+ return value.map((item) => absolutizeLinks(item, appOrigin));
265
+ if (!value || typeof value !== 'object')
266
+ return value;
267
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
268
+ key,
269
+ LINK_KEYS.has(key) &&
270
+ typeof item === 'string' &&
271
+ item.startsWith('/') &&
272
+ !item.startsWith('//')
273
+ ? new URL(item, appOrigin).toString()
274
+ : absolutizeLinks(item, appOrigin),
275
+ ]));
276
+ }
277
+ function withAbsoluteLinks(result, appOrigin) {
278
+ const typed = result;
279
+ if (!appOrigin || !typed?.structuredContent)
280
+ return result;
281
+ const structuredContent = absolutizeLinks(typed.structuredContent, appOrigin);
282
+ return {
283
+ ...typed,
284
+ structuredContent,
285
+ content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
286
+ };
287
+ }
288
+ /**
289
+ * Wraps every tool so API failures keep their code, scopes, and hint, and
290
+ * web links in results are absolute.
291
+ */
292
+ function withApiErrorResults(server, appOrigin) {
293
+ const register = server.registerTool.bind(server);
294
+ server.registerTool = (name, config, callback) => register(name, config, (async (...args) => {
295
+ try {
296
+ return withAbsoluteLinks(await callback(...args), appOrigin);
297
+ }
298
+ catch (error) {
299
+ if (error instanceof ToDoddleApiError)
300
+ return apiErrorResult(error);
301
+ throw error;
302
+ }
303
+ }));
304
+ }
232
305
  function toolResult(value) {
233
306
  return {
234
307
  // Keep text content for clients that do not consume structuredContent, but
@@ -338,8 +411,35 @@ const completeUploadInputSchema = z.object({
338
411
  error: z.string().max(1000).optional(),
339
412
  idempotencyKey: z.string().min(8),
340
413
  });
414
+ const EMBEDDABLE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
415
+ /** Escapes a file name for use as Markdown image alt text. */
416
+ function markdownAlt(value) {
417
+ return value.replace(/[\\[\]]/g, '\\$&');
418
+ }
419
+ /**
420
+ * Adds what an agent needs next: `embedMarkdown` to show an image inline in a
421
+ * Note or Context, and a `notice` when the stored type differs. ToDoddle stores
422
+ * SVG, HTML, and other active content as an opaque file so it never runs in a
423
+ * browser; an SVG previews and embeds through a PNG rendering instead.
424
+ */
425
+ function withUploadHints(result, requestedType, fileName) {
426
+ const entity = asRecord(result.entity);
427
+ const storedType = entity?.mimeType;
428
+ const id = typeof entity?.id === 'string' ? entity.id : undefined;
429
+ const isSvg = /\.svg$/i.test(fileName);
430
+ const hints = {};
431
+ if (id && typeof storedType === 'string' && (EMBEDDABLE_IMAGE_TYPES.has(storedType) || isSvg)) {
432
+ hints.embedMarkdown = `![${markdownAlt(fileName)}](/api/documents/${id}/${isSvg ? 'thumbnail' : 'embed'})`;
433
+ }
434
+ if (typeof storedType === 'string' && storedType !== requestedType) {
435
+ hints.notice = isSvg
436
+ ? `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.`
437
+ : `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.`;
438
+ }
439
+ return { ...result, ...hints };
440
+ }
341
441
  async function uploadDocument(api, options, input) {
342
- const prepared = await prepareUploadSource(input, options.uploadRoots, options.maxUploadBytes, options.managedUploadRoot);
442
+ const prepared = await prepareUploadSource(input, options.maxUploadBytes, options.managedUploadRoot);
343
443
  let session = null;
344
444
  let bytesUploaded = false;
345
445
  let uploadConfirmed = false;
@@ -357,7 +457,7 @@ async function uploadDocument(api, options, input) {
357
457
  bytesUploaded = true;
358
458
  const result = await api.post(`/api/external/projects/${input.projectId}/documents/${session.documentId}/finalize`, { success: true, taskId: input.taskId }, `${idempotencyKey}-finalize`);
359
459
  uploadConfirmed = true;
360
- return result;
460
+ return withUploadHints(result, prepared.contentType, prepared.fileName);
361
461
  }
362
462
  catch (error) {
363
463
  if (session && !bytesUploaded) {
@@ -426,11 +526,12 @@ const securityEventTypes = [
426
526
  ];
427
527
  export function createToDoddleMcpServer(api, serverOptions = {}) {
428
528
  const options = {
429
- uploadRoots: serverOptions.uploadRoots ?? [],
430
529
  managedUploadRoot: serverOptions.managedUploadRoot,
530
+ appOrigin: serverOptions.appOrigin,
431
531
  maxUploadBytes: serverOptions.maxUploadBytes ?? 1024 * 1024 * 1024,
432
532
  };
433
533
  const server = new McpServer({ name: 'tododdle', version: packageVersion.version }, { instructions: TODODDLE_WORKFLOW_GUIDANCE });
534
+ withApiErrorResults(server, options.appOrigin);
434
535
  server.registerTool('list_projects', {
435
536
  description: 'List projects accessible through this Agent Connection.',
436
537
  inputSchema: z.object({
@@ -1177,7 +1278,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1177
1278
  },
1178
1279
  }, async ({ projectId, noteId }) => toolResult(await api.get(`/api/external/notes/${noteId}`, { projectId })));
1179
1280
  server.registerTool('create_note', {
1180
- description: 'Create a workspace or project Note. Supply projectId for a project Note.',
1281
+ 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
1282
  inputSchema: z.object({
1182
1283
  projectId: z.string().min(1).optional(),
1183
1284
  title: z.string().min(1).max(500),
@@ -1193,7 +1294,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1193
1294
  },
1194
1295
  }, async ({ idempotencyKey, ...body }) => toolResult(await api.post('/api/external/notes', body, idempotencyKey)));
1195
1296
  server.registerTool('update_note', {
1196
- description: 'Update a workspace or project Note using optimistic concurrency.',
1297
+ 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
1298
  inputSchema: z.object({
1198
1299
  projectId: z.string().min(1).optional(),
1199
1300
  noteId: z.string().min(1),
@@ -1263,9 +1364,16 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1263
1364
  idempotentHint: true,
1264
1365
  openWorldHint: false,
1265
1366
  },
1266
- }, async ({ projectId, documentId, idempotencyKey, ...body }) => toolResult(await api.post(`/api/external/projects/${projectId}/documents/${documentId}/finalize`, body, idempotencyKey)));
1367
+ }, async ({ projectId, documentId, idempotencyKey, ...body }) => {
1368
+ const result = await api.post(`/api/external/projects/${projectId}/documents/${documentId}/finalize`, body, idempotencyKey);
1369
+ // The remote client sent the bytes, so the stored type is the only type to compare.
1370
+ const entity = asRecord(result.entity);
1371
+ const fileName = typeof entity?.fileName === 'string' ? entity.fileName : '';
1372
+ const storedType = typeof entity?.mimeType === 'string' ? entity.mimeType : '';
1373
+ return toolResult(body.success === false ? result : withUploadHints(result, storedType, fileName));
1374
+ });
1267
1375
  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.',
1376
+ 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
1377
  inputSchema: uploadInputSchema,
1270
1378
  annotations: {
1271
1379
  readOnlyHint: false,
@@ -1275,7 +1383,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1275
1383
  },
1276
1384
  }, async (input) => toolResult(await uploadDocument(api, options, input)));
1277
1385
  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.',
1386
+ 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
1387
  inputSchema: taskUploadInputSchema,
1280
1388
  annotations: {
1281
1389
  readOnlyHint: false,
@@ -1394,7 +1502,7 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1394
1502
  },
1395
1503
  }, async ({ taskId }) => toolResult(await api.delete(`/api/external/focus/${taskId}`, {})));
1396
1504
  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.',
1505
+ 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. 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
1506
  inputSchema: z.object({
1399
1507
  projectId: z.string().min(1),
1400
1508
  query: z.string().trim().min(2).max(200),
@@ -1756,6 +1864,49 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
1756
1864
  openWorldHint: false,
1757
1865
  },
1758
1866
  }, async ({ taskId, runId, projectId, ...body }) => toolResult(await api.post(`/api/external/tasks/${taskId}/agent-runs/${runId}`, body, undefined, runContext(runId, projectId, taskId, 'RECORD_EVIDENCE'))));
1867
+ server.registerTool('log_completed_work', {
1868
+ description: 'Record finished ad-hoc work in one call, for quick requests done in conversation without claiming a ticket first. ' +
1869
+ '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. ' +
1870
+ '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. ' +
1871
+ 'Use claim_ticket and finish_agent_run for planned work. Reuse the same idempotencyKey to retry safely.',
1872
+ inputSchema: z.object({
1873
+ projectId: z.string().min(1),
1874
+ taskId: z.string().min(1).optional(),
1875
+ ticket: z
1876
+ .object({
1877
+ planId: z.string().min(1),
1878
+ sectionId: z.string().min(1),
1879
+ title: z.string().trim().min(1).max(500),
1880
+ description: z.string().trim().max(50_000).optional(),
1881
+ kind: taskKindSchema.optional(),
1882
+ priority: prioritySchema.optional(),
1883
+ })
1884
+ .optional(),
1885
+ runId: z.string().min(1).max(200).optional(),
1886
+ outcome: z.string().trim().min(1).max(2000),
1887
+ evidence: z
1888
+ .array(z.object({
1889
+ type: agentRunEvidenceTypeSchema,
1890
+ reference: z.string().trim().min(1).max(2000),
1891
+ label: z.string().trim().min(1).max(200).optional(),
1892
+ }))
1893
+ .max(25)
1894
+ .default([]),
1895
+ status: z.enum(['COMPLETE', 'REVIEW']).default('COMPLETE'),
1896
+ idempotencyKey: z.string().trim().min(8).max(180),
1897
+ }),
1898
+ annotations: {
1899
+ readOnlyHint: false,
1900
+ destructiveHint: false,
1901
+ idempotentHint: true,
1902
+ openWorldHint: false,
1903
+ },
1904
+ }, async ({ projectId, ...body }) => {
1905
+ if (Boolean(body.taskId) === Boolean(body.ticket)) {
1906
+ throw new Error('Send taskId for an existing ticket or ticket to create one, not both.');
1907
+ }
1908
+ return toolResult(await api.post(`/api/external/projects/${projectId}/completed-work`, body));
1909
+ });
1759
1910
  server.registerTool('get_supervisor_state', {
1760
1911
  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
1912
  inputSchema: z.object({
@@ -2228,7 +2379,10 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2228
2379
  },
2229
2380
  }, async ({ projectId, artifactId, idempotencyKey, ...body }) => toolResult(await api.post(`/api/external/projects/${projectId}/artifacts/${artifactId}/relations`, body, idempotencyKey)));
2230
2381
  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.',
2382
+ description: 'Update artifact Markdown, its current short summary, or metadata using optimistic concurrency. Editing approved content returns it to draft. ' +
2383
+ '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). ' +
2384
+ '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. ' +
2385
+ '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
2386
  inputSchema: z.object({
2233
2387
  projectId: z.string().min(1),
2234
2388
  artifactId: z.string().min(1),
@@ -2236,10 +2390,36 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2236
2390
  title: z.string().min(1).optional(),
2237
2391
  summary: z.string().trim().max(500).nullable().optional(),
2238
2392
  content: z.string().optional(),
2393
+ contentEdits: z
2394
+ .array(z.discriminatedUnion('op', [
2395
+ z.object({
2396
+ op: z.literal('replace_section'),
2397
+ heading: z.string().min(1).max(500),
2398
+ content: z.string(),
2399
+ }),
2400
+ z.object({
2401
+ op: z.literal('append_section'),
2402
+ heading: z.string().min(1).max(200),
2403
+ content: z.string(),
2404
+ parent: z.string().min(1).max(500).optional(),
2405
+ level: z.number().int().min(1).max(6).optional(),
2406
+ }),
2407
+ z.object({
2408
+ op: z.literal('find_replace'),
2409
+ find: z.string().min(1),
2410
+ replace: z.string(),
2411
+ expectedCount: z.number().int().min(1).max(1000).optional(),
2412
+ }),
2413
+ ]))
2414
+ .min(1)
2415
+ .max(20)
2416
+ .optional(),
2417
+ baseVersion: z.number().int().min(1).optional(),
2239
2418
  parentArtifactId: z.string().min(1).nullable().optional(),
2240
2419
  ownerId: z.string().optional(),
2241
2420
  metadata: z.record(z.unknown()).optional(),
2242
2421
  changeNote: z.string().optional(),
2422
+ verbose: z.boolean().default(false),
2243
2423
  }),
2244
2424
  annotations: {
2245
2425
  readOnlyHint: false,
@@ -2247,7 +2427,15 @@ export function createToDoddleMcpServer(api, serverOptions = {}) {
2247
2427
  idempotentHint: true,
2248
2428
  openWorldHint: false,
2249
2429
  },
2250
- }, async ({ projectId, artifactId, ...body }) => toolResult(await api.patch(`/api/external/projects/${projectId}/artifacts/${artifactId}`, body)));
2430
+ }, async ({ projectId, artifactId, verbose, ...body }) => {
2431
+ const result = await api.patch(`/api/external/projects/${projectId}/artifacts/${artifactId}${verbose ? '' : '?detail=summary'}`, body);
2432
+ // An older ToDoddle server strips contentEdits it does not know and reports success.
2433
+ const changedFields = result.changedFields;
2434
+ if (body.contentEdits && Array.isArray(changedFields) && !changedFields.includes('content')) {
2435
+ throw new Error('This ToDoddle server does not support contentEdits yet. Send the full content instead.');
2436
+ }
2437
+ return toolResult(result);
2438
+ });
2251
2439
  server.registerTool('transition_project_artifact', {
2252
2440
  description: 'Submit, approve, supersede, archive, or return a project artifact to draft.',
2253
2441
  inputSchema: z.object({