Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions src/mcp-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,53 @@ import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/m
import type { ZodTypeAny, z } from 'zod'
import type { TodoistTool } from './todoist-tool.js'

function textContent(text: string) {
return {
content: [{ type: 'text' as const, text }],
/**
* Wether to return the structured content directly, vs. in the `content` part of the output.
*
* The `structuredContent` part of the output is relatively new in the spec, and it's not yet
* supported by all clients. This flag controls wether we return the structured content using this
* new feature of the MCP protocol or not.
*
* If `false`, the `structuredContent` will be returned as stringified JSON in one of the `content`
* parts.
*
* Eventually we should be able to remove this, and change the code to always work with the
* structured content returned directly, once most or all MCP clients support it.
*/
const USE_STRUCTURED_CONTENT =
process.env.USE_STRUCTURED_CONTENT === 'true' || process.env.NODE_ENV === 'test'

/**
* Get the output payload for a tool, in the correct format expected by MCP client apps.
*
* @param textContent - The text content to return.
* @param structuredContent - The structured content to return.
* @returns The output payload.
* @see USE_STRUCTURED_CONTENT - Wether to use the structured content feature of the MCP protocol.
*/
function getToolOutput<StructuredContent extends Record<string, unknown>>({
textContent,
structuredContent,
}: { textContent: string; structuredContent: StructuredContent }) {
if (USE_STRUCTURED_CONTENT) {
return {
content: [{ type: 'text' as const, text: textContent }],
structuredContent,
}
}
}

function jsonContent(data: unknown) {
const json = JSON.stringify(structuredContent)
return {
content: [
{
type: 'text' as const,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
{ type: 'text' as const, text: textContent },
{ type: 'text' as const, mimeType: 'application/json', text: json },
],
}
}

function textOrJsonContent(data: unknown) {
return typeof data === 'string' ? textContent(data) : jsonContent(data)
}

function errorContent(error: string) {
function getErrorOutput(error: string) {
return {
...textContent(error),
content: [{ type: 'text' as const, text: error }],
isError: true,
}
}
Expand All @@ -50,18 +72,18 @@ function registerTool<Params extends z.ZodRawShape>(
) => {
try {
const result = await tool.execute(args as z.infer<z.ZodObject<Params>>, client)
return textOrJsonContent(result)
return result
} catch (error) {
console.error(`Error executing tool ${tool.name}:`, {
args,
error,
})
const message = error instanceof Error ? error.message : 'An unknown error occurred'
return errorContent(message)
return getErrorOutput(message)
}
}

server.tool(tool.name, tool.description, tool.parameters, cb)
}

export { registerTool, errorContent }
export { registerTool, getToolOutput }
25 changes: 25 additions & 0 deletions src/tools/__tests__/__snapshots__/delete-one.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`delete-one tool deleting projects should delete a project by ID 1`] = `
"Deleted project: id=6cfCcrrCFg2xP94Q
Next:
- Use projects-list to see remaining projects
- Note: All tasks and sections in this project were also deleted
- Use overview to review your updated project structure"
`;

exports[`delete-one tool deleting sections should delete a section by ID 1`] = `
"Deleted section: id=section-123
Next:
- Use sections-search to see remaining sections in the project
- Note: Tasks in this section were also deleted
- Use tasks-list-for-container with type=project to see unorganized tasks"
`;

exports[`delete-one tool deleting tasks should delete a task by ID 1`] = `
"Deleted task: id=8485093748
Next:
- Use tasks-list-by-date to see remaining tasks for today
- Use overview to check if this affects any dependent tasks
- Note: Any subtasks of this task were also deleted"
`;
46 changes: 46 additions & 0 deletions src/tools/__tests__/__snapshots__/projects-list.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`projects-list tool listing all projects should handle pagination with limit and cursor 1`] = `
"Projects: 1 (limit 10), more available.
Preview:
First Project • id=project-1
Next:
- Use tasks-list-for-container with projectId to see tasks in specific projects.
- Pass cursor 'next-page-cursor' to fetch more results."
`;

exports[`projects-list tool listing all projects should list all projects when no search parameter is provided 1`] = `
"Projects: 3 (limit 50).
Preview:
Inbox • Inbox • id=inbox-project-id
test-abc123def456-project • id=6cfCcrrCFg2xP94Q
Work Project • ⭐ • Shared • board • id=work-project-id
Next:
- Use tasks-list-for-container with projectId to see tasks in specific projects.
- Favorite projects appear first in most Todoist views."
`;

exports[`projects-list tool searching projects should filter projects by search term (case insensitive) 1`] = `
"Projects matching "work": 2 (limit 50).
Filter: search: "work".
Preview:
Work Project • id=work-project-id
Hobby Work • id=hobby-project-id
Next:
- Use tasks-list-for-container with projectId to see tasks in specific projects."
`;

exports[`projects-list tool searching projects should handle search with case insensitive matching 1`] = `
"Projects matching "IMPORTANT": 1 (limit 50).
Filter: search: "IMPORTANT".
Preview:
Important Project • id=6cfCcrrCFg2xP94Q
Next:
- Use tasks-list-for-container with projectId to see tasks in specific projects."
`;

exports[`projects-list tool searching projects should handle search with no matches 1`] = `
"Projects matching "nonexistent": 0 (limit 50).
Filter: search: "nonexistent".
No results. Try broader search terms; Check spelling; Remove search to see all projects."
`;
25 changes: 25 additions & 0 deletions src/tools/__tests__/__snapshots__/projects-manage.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`projects-manage tool creating a new project should create a project and return mapped result 1`] = `
"Created project: test-abc123def456-project • id=6cfCcrrCFg2xP94Q
Next:
- Use sections-manage to organize this project with sections
- Use tasks-add-multiple to add your first tasks
- Use overview with projectId to see updated project structure."
`;

exports[`projects-manage tool creating a new project should handle different project properties from API 1`] = `
"Created project: My Blue Project • id=project-456
Next:
- Use sections-manage to organize this project with sections
- Use tasks-add-multiple to add your first tasks
- Use overview with projectId to see updated project structure."
`;

exports[`projects-manage tool updating an existing project should update a project when id is provided 1`] = `
"Updated project: Updated Project Name • id=existing-project-123
Next:
- Use overview with projectId=existing-project-123 to see project structure
- Use projects-list to see all projects with updated name
- Use tasks-list-for-container to review existing tasks"
`;
33 changes: 33 additions & 0 deletions src/tools/__tests__/__snapshots__/sections-manage.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`sections-manage tool creating a new section should create a section and return result 1`] = `
"Created section: test-abc123def456-section • id=section-123
Next:
- Use tasks-add-multiple with sectionId=section-123 to add your first tasks
- Use tasks-list-for-container with type=section and id=section-123 to verify setup
- Use overview with projectId=6cfCcrrCFg2xP94Q to see project organization"
`;

exports[`sections-manage tool creating a new section should handle different section properties from API 1`] = `
"Created section: My Section Name • id=section-456
Next:
- Use tasks-add-multiple with sectionId=section-456 to add your first tasks
- Use tasks-list-for-container with type=section and id=section-456 to verify setup
- Use overview with projectId=project-789 to see project organization"
`;

exports[`sections-manage tool updating an existing section should update a section when id is provided 1`] = `
"Updated section: Updated Section Name • id=existing-section-123
Next:
- Use tasks-list-for-container with type=section and id=existing-section-123 to see existing tasks
- Use sections-search to see all sections in this project
- Consider updating task descriptions if section purpose changed"
`;

exports[`sections-manage tool updating an existing section should update section without requiring projectId 1`] = `
"Updated section: Section New Name • id=section-update-test
Next:
- Use tasks-list-for-container with type=section and id=section-update-test to see existing tasks
- Use sections-search to see all sections in this project
- Consider updating task descriptions if section purpose changed"
`;
66 changes: 66 additions & 0 deletions src/tools/__tests__/__snapshots__/sections-search.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`sections-search tool listing all sections in a project should handle project with no sections 1`] = `
"Sections in project empty-project-id: 0.
No results. Project has no sections yet; Use sections-manage to create sections."
`;

exports[`sections-search tool listing all sections in a project should list all sections when no search parameter is provided 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q: 4.
Preview:
To Do • id=section-123
In Progress • id=section-456
Done • id=section-789
Backlog Items • id=section-999
Next:
- Use tasks-list-for-container with type=section to see tasks in any section
- Use sections-manage to modify section names or order"
`;

exports[`sections-search tool searching sections by name should filter sections by search term (case insensitive) 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q matching "progress": 2.
Preview:
In Progress • id=section-456
Progress Review • id=section-999
Next:
- Use tasks-list-for-container with type=section to see tasks in any section
- Use sections-manage to modify section names or order
- Remove search parameter to see all sections in this project"
`;

exports[`sections-search tool searching sections by name should handle case sensitive search correctly 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q matching "IMPORTANT": 1.
Preview:
Important Tasks • id=section-123
Next:
- Use tasks-list-for-container with type=section and id=section-123 to see tasks
- Use sections-manage to create additional sections for organization
- Remove search parameter to see all sections in this project"
`;

exports[`sections-search tool searching sections by name should handle exact matches 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q matching "done": 2.
Preview:
Done • id=section-123
Done Soon • id=section-456
Next:
- Use tasks-list-for-container with type=section to see tasks in any section
- Use sections-manage to modify section names or order
- Remove search parameter to see all sections in this project"
`;

exports[`sections-search tool searching sections by name should handle partial matches correctly 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q matching "task": 2.
Preview:
Development Tasks • id=section-123
Testing Tasks • id=section-456
Next:
- Use tasks-list-for-container with type=section to see tasks in any section
- Use sections-manage to modify section names or order
- Remove search parameter to see all sections in this project"
`;

exports[`sections-search tool searching sections by name should handle search with no matches 1`] = `
"Sections in project 6cfCcrrCFg2xP94Q matching "nonexistent": 0.
No results. Try broader search terms; Check spelling; Remove search to see all sections."
`;
43 changes: 43 additions & 0 deletions src/tools/__tests__/__snapshots__/tasks-add-multiple.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`tasks-add-multiple tool adding multiple tasks should add multiple tasks and return mapped results 1`] = `
"Added 2 tasks to specified project.
Tasks:
First task content • P1 • id=8485093748
Second task content • due 2025-08-15 • P2 • id=8485093749.
Next:
- Use overview to see your updated project organization"
`;

exports[`tasks-add-multiple tool adding multiple tasks should add tasks with duration 1`] = `
"Added 2 tasks to specified project.
Tasks:
Task with 2 hour duration • P1 • id=8485093752
Task with 45 minute duration • P1 • id=8485093753.
Next:
- Use overview to see your updated project organization"
`;

exports[`tasks-add-multiple tool adding multiple tasks should handle tasks with section and parent IDs 1`] = `
"Added 1 task to specified project.
Tasks:
Subtask content • P3 • id=8485093750.
Next:
- Use overview to see your updated project organization"
`;

exports[`tasks-add-multiple tool next steps logic should suggest overview tool when no hasToday context 1`] = `
"Added 1 task to specified project.
Tasks:
Regular task • P1 • id=8485093756.
Next:
- Use overview to see your updated project organization"
`;

exports[`tasks-add-multiple tool next steps logic should suggest tasks-list-by-date for today when hasToday is true 1`] = `
"Added 1 task.
Tasks:
Task due today • due 2025-08-17 • P1 • id=8485093755.
Next:
- Use overview to see your updated project organization"
`;
Loading