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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +31 -7
- package/config/external-api-parity.json +2 -0
- package/dist/api-client.d.ts +8 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +24 -2
- package/dist/api-client.js.map +1 -1
- package/dist/compatibility.d.ts +2 -2
- package/dist/compatibility.js +1 -1
- package/dist/config.d.ts +0 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -8
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +21 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +221 -16
- package/dist/server.js.map +1 -1
- package/dist/upload-source.d.ts +1 -1
- package/dist/upload-source.d.ts.map +1 -1
- package/dist/upload-source.js +20 -13
- package/dist/upload-source.js.map +1 -1
- package/dist/workflow-guidance.d.ts +1 -1
- package/dist/workflow-guidance.d.ts.map +1 -1
- package/dist/workflow-guidance.js +11 -1
- package/dist/workflow-guidance.js.map +1 -1
- package/package.json +1 -1
- package/skills/tododdle-workflow/SKILL.md +2 -0
- package/skills/tododdle-workflow/references/files.md +2 -2
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 = ``;
|
|
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.
|
|
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 ; 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 ; 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 }) =>
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 }) =>
|
|
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({
|