Skip to main content

things_mcp/
server.rs

1//! `rmcp` `ServerHandler` implementation. Tools are registered with
2//! `#[tool_router]` and each delegates to a `tools::*` function. Outputs are
3//! returned as `Json<T>` — `rmcp` serialises and emits the structured payload.
4
5use rmcp::handler::server::wrapper::Parameters;
6use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
7use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, Json, ServerHandler};
8
9use crate::core::types::{AreaList, MaybeProject, MaybeTodo, ProjectList, TodoList};
10use crate::tools::todos::{
11    things_add_todo, things_assign_tag, things_cancel_todo, things_complete_todo,
12    things_get_todo, things_move_todo, things_unassign_tag, things_update_todo,
13    AddTodoArgs, GetTodoArgs, MoveTodoArgs, StatusChangeArgs, TagAssignmentArgs,
14    UpdateTodoArgs,
15};
16use crate::core::writer::outcome::WriteOutcome;
17use crate::tools::projects::{
18    things_add_project, things_get_project, things_update_project,
19    AddProjectArgs, GetProjectArgs, UpdateProjectArgs,
20};
21use crate::tools::bulk::{things_bulk_json, BulkJsonArgs};
22use crate::tools::search::{things_search, SearchArgs};
23use crate::state::AppState;
24use crate::tools::lists::{
25    things_list_anytime, things_list_areas, things_list_by_tag, things_list_inbox,
26    things_list_logbook, things_list_projects, things_list_someday,
27    things_list_today, things_list_trash, things_list_upcoming, ListAnytimeArgs,
28    ListAreasArgs, ListByTagArgs, ListInboxArgs, ListLogbookArgs, ListProjectsArgs,
29    ListSomedayArgs, ListTodayArgs, ListTrashArgs, ListUpcomingArgs,
30};
31use crate::core::applescript::admin::TagOutcome;
32use crate::core::reader::tags::TagListing;
33use crate::tools::tags::{
34    things_create_tag, things_delete_tag, things_list_tags, things_merge_tags,
35    things_move_tag, things_rename_tag,
36    CreateTagArgs, DeleteTagArgs, ListTagsArgs, MergeTagsArgs, MoveTagArgs, RenameTagArgs,
37};
38
39#[derive(Clone)]
40pub struct ThingsServer {
41    pub state: AppState,
42}
43
44#[tool_router]
45impl ThingsServer {
46    pub fn new(state: AppState) -> Self {
47        Self { state }
48    }
49
50    #[tool(
51        name = "things_list_inbox",
52        description = "Return to-dos in the Things Inbox. Read-only.",
53        annotations(
54            read_only_hint = true,
55            destructive_hint = false,
56            idempotent_hint = true,
57            open_world_hint = false
58        )
59    )]
60    async fn tool_list_inbox(
61        &self,
62        Parameters(args): Parameters<ListInboxArgs>,
63    ) -> Result<Json<TodoList>, McpError> {
64        let state = self.state.clone();
65        let rows = things_list_inbox(state, args)
66            .await
67            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
68        Ok(Json(rows))
69    }
70
71    #[tool(
72        name = "things_list_today",
73        description = "Return to-dos scheduled for today (start = Anytime with startDate ≤ today). Read-only.",
74        annotations(
75            read_only_hint = true,
76            destructive_hint = false,
77            idempotent_hint = true,
78            open_world_hint = false
79        )
80    )]
81    async fn tool_list_today(
82        &self,
83        Parameters(args): Parameters<ListTodayArgs>,
84    ) -> Result<Json<TodoList>, McpError> {
85        let rows = things_list_today(self.state.clone(), args)
86            .await
87            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
88        Ok(Json(rows))
89    }
90
91    #[tool(
92        name = "things_list_upcoming",
93        description = "Return scheduled or deadlined to-dos in the future. Read-only.",
94        annotations(
95            read_only_hint = true,
96            destructive_hint = false,
97            idempotent_hint = true,
98            open_world_hint = false
99        )
100    )]
101    async fn tool_list_upcoming(
102        &self,
103        Parameters(args): Parameters<ListUpcomingArgs>,
104    ) -> Result<Json<TodoList>, McpError> {
105        let rows = things_list_upcoming(self.state.clone(), args)
106            .await
107            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
108        Ok(Json(rows))
109    }
110
111    #[tool(
112        name = "things_list_anytime",
113        description = "Return Anytime to-dos (start=Anytime, no scheduled date). Optionally filter by area. Read-only.",
114        annotations(
115            read_only_hint = true,
116            destructive_hint = false,
117            idempotent_hint = true,
118            open_world_hint = false
119        )
120    )]
121    async fn tool_list_anytime(
122        &self,
123        Parameters(args): Parameters<ListAnytimeArgs>,
124    ) -> Result<Json<TodoList>, McpError> {
125        let rows = things_list_anytime(self.state.clone(), args)
126            .await
127            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
128        Ok(Json(rows))
129    }
130
131    #[tool(
132        name = "things_list_someday",
133        description = "Return Someday to-dos (start = Someday). Read-only.",
134        annotations(
135            read_only_hint = true,
136            destructive_hint = false,
137            idempotent_hint = true,
138            open_world_hint = false
139        )
140    )]
141    async fn tool_list_someday(
142        &self,
143        Parameters(args): Parameters<ListSomedayArgs>,
144    ) -> Result<Json<TodoList>, McpError> {
145        let rows = things_list_someday(self.state.clone(), args)
146            .await
147            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
148        Ok(Json(rows))
149    }
150
151    #[tool(
152        name = "things_list_logbook",
153        description = "Return completed or canceled to-dos, newest first. Read-only.",
154        annotations(
155            read_only_hint = true,
156            destructive_hint = false,
157            idempotent_hint = true,
158            open_world_hint = false
159        )
160    )]
161    async fn tool_list_logbook(
162        &self,
163        Parameters(args): Parameters<ListLogbookArgs>,
164    ) -> Result<Json<TodoList>, McpError> {
165        let rows = things_list_logbook(self.state.clone(), args)
166            .await
167            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
168        Ok(Json(rows))
169    }
170
171    #[tool(
172        name = "things_list_trash",
173        description = "Return trashed to-dos, newest first. Read-only.",
174        annotations(
175            read_only_hint = true,
176            destructive_hint = false,
177            idempotent_hint = true,
178            open_world_hint = false
179        )
180    )]
181    async fn tool_list_trash(
182        &self,
183        Parameters(args): Parameters<ListTrashArgs>,
184    ) -> Result<Json<TodoList>, McpError> {
185        let rows = things_list_trash(self.state.clone(), args)
186            .await
187            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
188        Ok(Json(rows))
189    }
190
191    #[tool(
192        name = "things_list_areas",
193        description = "Return all areas, ordered by display index. Read-only.",
194        annotations(
195            read_only_hint = true,
196            destructive_hint = false,
197            idempotent_hint = true,
198            open_world_hint = false
199        )
200    )]
201    async fn tool_list_areas(
202        &self,
203        Parameters(args): Parameters<ListAreasArgs>,
204    ) -> Result<Json<AreaList>, McpError> {
205        let rows = things_list_areas(self.state.clone(), args)
206            .await
207            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
208        Ok(Json(rows))
209    }
210
211    #[tool(
212        name = "things_list_projects",
213        description = "Return projects, optionally restricted to a single area and/or a status filter (open/done/all). Read-only.",
214        annotations(
215            read_only_hint = true,
216            destructive_hint = false,
217            idempotent_hint = true,
218            open_world_hint = false
219        )
220    )]
221    async fn tool_list_projects(
222        &self,
223        Parameters(args): Parameters<ListProjectsArgs>,
224    ) -> Result<Json<ProjectList>, McpError> {
225        let rows = things_list_projects(self.state.clone(), args)
226            .await
227            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
228        Ok(Json(rows))
229    }
230
231    #[tool(
232        name = "things_list_tags",
233        description = "Return all tags. `flat` is the every-tag list; `roots` is a tree of `TagNode`s rooted at parentless tags. Read-only.",
234        annotations(
235            read_only_hint = true,
236            destructive_hint = false,
237            idempotent_hint = true,
238            open_world_hint = false
239        )
240    )]
241    async fn tool_list_tags(
242        &self,
243        Parameters(args): Parameters<ListTagsArgs>,
244    ) -> Result<Json<TagListing>, McpError> {
245        let listing = things_list_tags(self.state.clone(), args)
246            .await
247            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
248        Ok(Json(listing))
249    }
250
251    #[tool(
252        name = "things_list_by_tag",
253        description = "Return to-dos carrying a given tag. `tag` accepts the tag's title or UUID. With `recurse=true` (default), descendants of the tag are included. Read-only.",
254        annotations(
255            read_only_hint = true,
256            destructive_hint = false,
257            idempotent_hint = true,
258            open_world_hint = false
259        )
260    )]
261    async fn tool_list_by_tag(
262        &self,
263        Parameters(args): Parameters<ListByTagArgs>,
264    ) -> Result<Json<TodoList>, McpError> {
265        let rows = things_list_by_tag(self.state.clone(), args)
266            .await
267            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
268        Ok(Json(rows))
269    }
270
271    #[tool(
272        name = "things_get_todo",
273        description = "Return a single to-do with notes, checklist, tags, and a repeating-template flag. Returns null if not found. Read-only.",
274        annotations(
275            read_only_hint = true,
276            destructive_hint = false,
277            idempotent_hint = true,
278            open_world_hint = false
279        )
280    )]
281    async fn tool_get_todo(
282        &self,
283        Parameters(args): Parameters<GetTodoArgs>,
284    ) -> Result<Json<MaybeTodo>, McpError> {
285        let res = things_get_todo(self.state.clone(), args)
286            .await
287            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
288        Ok(Json(res))
289    }
290
291    #[tool(
292        name = "things_get_project",
293        description = "Return a single project with its child to-dos and headings. Returns null if not found. Read-only.",
294        annotations(
295            read_only_hint = true,
296            destructive_hint = false,
297            idempotent_hint = true,
298            open_world_hint = false
299        )
300    )]
301    async fn tool_get_project(
302        &self,
303        Parameters(args): Parameters<GetProjectArgs>,
304    ) -> Result<Json<MaybeProject>, McpError> {
305        let res = things_get_project(self.state.clone(), args)
306            .await
307            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
308        Ok(Json(res))
309    }
310
311    #[tool(
312        name = "things_search",
313        description = "Search to-dos by free text (title + notes) and structured filters (tags, area, project, status, deadline range, scheduled range). Read-only.",
314        annotations(
315            read_only_hint = true,
316            destructive_hint = false,
317            idempotent_hint = true,
318            open_world_hint = false
319        )
320    )]
321    async fn tool_search(
322        &self,
323        Parameters(args): Parameters<SearchArgs>,
324    ) -> Result<Json<TodoList>, McpError> {
325        let rows = things_search(self.state.clone(), args)
326            .await
327            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
328        Ok(Json(rows))
329    }
330
331    #[tool(
332        name = "things_add_todo",
333        description = "Create a new to-do in Things. Returns a WriteOutcome with the new id once verified by polling the SQLite reader. Requires `title`; all other fields are optional. Open-world: side-effects the live Things app via the JSON URL scheme.",
334        annotations(
335            read_only_hint = false,
336            destructive_hint = false,
337            idempotent_hint = false,
338            open_world_hint = true
339        )
340    )]
341    async fn tool_add_todo(
342        &self,
343        Parameters(args): Parameters<AddTodoArgs>,
344    ) -> Result<Json<WriteOutcome>, McpError> {
345        let out = things_add_todo(self.state.clone(), args)
346            .await
347            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
348        Ok(Json(out))
349    }
350
351    #[tool(
352        name = "things_add_project",
353        description = "Create a new project in Things, optionally with initial headings nested inside. Returns a WriteOutcome with the new id once verified.",
354        annotations(
355            read_only_hint = false,
356            destructive_hint = false,
357            idempotent_hint = false,
358            open_world_hint = true
359        )
360    )]
361    async fn tool_add_project(
362        &self,
363        Parameters(args): Parameters<AddProjectArgs>,
364    ) -> Result<Json<WriteOutcome>, McpError> {
365        let out = things_add_project(self.state.clone(), args)
366            .await
367            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
368        Ok(Json(out))
369    }
370
371    #[tool(
372        name = "things_update_todo",
373        description = "Update an existing to-do's title, notes, scheduling, tags, list, or status. Only populated fields are sent. Requires the Things auth-token.",
374        annotations(
375            read_only_hint = false,
376            destructive_hint = false,
377            idempotent_hint = false,
378            open_world_hint = true
379        )
380    )]
381    async fn tool_update_todo(
382        &self,
383        Parameters(args): Parameters<UpdateTodoArgs>,
384    ) -> Result<Json<WriteOutcome>, McpError> {
385        let out = things_update_todo(self.state.clone(), args)
386            .await
387            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
388        Ok(Json(out))
389    }
390
391    #[tool(
392        name = "things_update_project",
393        description = "Update an existing project's title, notes, scheduling, tags, parent area, or status. Only populated fields are sent. Requires the Things auth-token.",
394        annotations(
395            read_only_hint = false,
396            destructive_hint = false,
397            idempotent_hint = false,
398            open_world_hint = true
399        )
400    )]
401    async fn tool_update_project(
402        &self,
403        Parameters(args): Parameters<UpdateProjectArgs>,
404    ) -> Result<Json<WriteOutcome>, McpError> {
405        let out = things_update_project(self.state.clone(), args)
406            .await
407            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
408        Ok(Json(out))
409    }
410
411    #[tool(
412        name = "things_complete_todo",
413        description = "Mark a to-do as completed. Idempotent: re-completing has no further effect. Requires the Things auth-token.",
414        annotations(
415            read_only_hint = false,
416            destructive_hint = false,
417            idempotent_hint = true,
418            open_world_hint = true
419        )
420    )]
421    async fn tool_complete_todo(
422        &self,
423        Parameters(args): Parameters<StatusChangeArgs>,
424    ) -> Result<Json<WriteOutcome>, McpError> {
425        let out = things_complete_todo(self.state.clone(), args)
426            .await
427            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
428        Ok(Json(out))
429    }
430
431    #[tool(
432        name = "things_cancel_todo",
433        description = "Mark a to-do as canceled (distinct from completed in Things). Idempotent. Requires the Things auth-token.",
434        annotations(
435            read_only_hint = false,
436            destructive_hint = false,
437            idempotent_hint = true,
438            open_world_hint = true
439        )
440    )]
441    async fn tool_cancel_todo(
442        &self,
443        Parameters(args): Parameters<StatusChangeArgs>,
444    ) -> Result<Json<WriteOutcome>, McpError> {
445        let out = things_cancel_todo(self.state.clone(), args)
446            .await
447            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
448        Ok(Json(out))
449    }
450
451    #[tool(
452        name = "things_move_todo",
453        description = "Move a to-do under a project, area, or to the Inbox (when list_id is omitted). Requires the Things auth-token.",
454        annotations(
455            read_only_hint = false,
456            destructive_hint = false,
457            idempotent_hint = false,
458            open_world_hint = true
459        )
460    )]
461    async fn tool_move_todo(
462        &self,
463        Parameters(args): Parameters<MoveTodoArgs>,
464    ) -> Result<Json<WriteOutcome>, McpError> {
465        let out = things_move_todo(self.state.clone(), args)
466            .await
467            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
468        Ok(Json(out))
469    }
470
471    #[tool(
472        name = "things_bulk_json",
473        description = "Power tool: send a raw array of Things JSON URL scheme operation objects. Max 250 elements. No per-element verification — WriteOutcome.verified is always false. Use individual tools when verification matters.",
474        annotations(
475            read_only_hint = false,
476            destructive_hint = true,
477            idempotent_hint = false,
478            open_world_hint = true
479        )
480    )]
481    async fn tool_bulk_json(
482        &self,
483        Parameters(args): Parameters<BulkJsonArgs>,
484    ) -> Result<Json<WriteOutcome>, McpError> {
485        let out = things_bulk_json(self.state.clone(), args)
486            .await
487            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
488        Ok(Json(out))
489    }
490
491    #[tool(
492        name = "things_assign_tag",
493        description = "Attach one or more tags to a to-do. Identifier is the to-do's uuid. Tags are referenced by name. Idempotent: reassigning an already-attached tag is a no-op. The implementation reads current tags and replays an `update` with the merged set; concurrent edits between the read and write may overwrite each other (≈100–300 ms window).",
494        annotations(
495            read_only_hint = false,
496            destructive_hint = false,
497            idempotent_hint = true,
498            open_world_hint = true
499        )
500    )]
501    async fn tool_assign_tag(
502        &self,
503        Parameters(args): Parameters<TagAssignmentArgs>,
504    ) -> Result<Json<WriteOutcome>, McpError> {
505        let out = things_assign_tag(self.state.clone(), args)
506            .await
507            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
508        Ok(Json(out))
509    }
510
511    #[tool(
512        name = "things_unassign_tag",
513        description = "Detach one or more tags from a to-do. Idempotent: removing a tag that wasn't attached is a no-op. Read-modify-write through Things' `update` op; concurrent edits between the read and write may overwrite each other (≈100–300 ms window).",
514        annotations(
515            read_only_hint = false,
516            destructive_hint = false,
517            idempotent_hint = true,
518            open_world_hint = true
519        )
520    )]
521    async fn tool_unassign_tag(
522        &self,
523        Parameters(args): Parameters<TagAssignmentArgs>,
524    ) -> Result<Json<WriteOutcome>, McpError> {
525        let out = things_unassign_tag(self.state.clone(), args)
526            .await
527            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
528        Ok(Json(out))
529    }
530
531    #[tool(
532        name = "things_create_tag",
533        description = "Create a new tag. Optionally nest it under an existing parent tag by name. Runs via AppleScript (`osascript`).",
534        annotations(
535            read_only_hint = false,
536            destructive_hint = false,
537            idempotent_hint = false,
538            open_world_hint = true
539        )
540    )]
541    async fn tool_create_tag(
542        &self,
543        Parameters(args): Parameters<CreateTagArgs>,
544    ) -> Result<Json<TagOutcome>, McpError> {
545        let out = things_create_tag(self.state.clone(), args)
546            .await
547            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
548        Ok(Json(out))
549    }
550
551    #[tool(
552        name = "things_rename_tag",
553        description = "Rename an existing tag globally. Every to-do that carried the old name will surface the new name. Runs via AppleScript.",
554        annotations(
555            read_only_hint = false,
556            destructive_hint = true,
557            idempotent_hint = false,
558            open_world_hint = true
559        )
560    )]
561    async fn tool_rename_tag(
562        &self,
563        Parameters(args): Parameters<RenameTagArgs>,
564    ) -> Result<Json<TagOutcome>, McpError> {
565        let out = things_rename_tag(self.state.clone(), args)
566            .await
567            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
568        Ok(Json(out))
569    }
570
571    #[tool(
572        name = "things_merge_tags",
573        description = "Reassign every to-do tagged `source` to also carry `target`, then delete `source`. Source and target must differ. Runs via AppleScript.",
574        annotations(
575            read_only_hint = false,
576            destructive_hint = true,
577            idempotent_hint = false,
578            open_world_hint = true
579        )
580    )]
581    async fn tool_merge_tags(
582        &self,
583        Parameters(args): Parameters<MergeTagsArgs>,
584    ) -> Result<Json<TagOutcome>, McpError> {
585        let out = things_merge_tags(self.state.clone(), args)
586            .await
587            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
588        Ok(Json(out))
589    }
590
591    #[tool(
592        name = "things_delete_tag",
593        description = "Delete a tag globally. To-dos that carry the tag stay; only the tag itself is removed. Runs via AppleScript.",
594        annotations(
595            read_only_hint = false,
596            destructive_hint = true,
597            idempotent_hint = false,
598            open_world_hint = true
599        )
600    )]
601    async fn tool_delete_tag(
602        &self,
603        Parameters(args): Parameters<DeleteTagArgs>,
604    ) -> Result<Json<TagOutcome>, McpError> {
605        let out = things_delete_tag(self.state.clone(), args)
606            .await
607            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
608        Ok(Json(out))
609    }
610
611    #[tool(
612        name = "things_move_tag",
613        description = "Move a tag under a new parent tag (or to the root when `new_parent` is omitted/null). Runs via AppleScript.",
614        annotations(
615            read_only_hint = false,
616            destructive_hint = false,
617            idempotent_hint = false,
618            open_world_hint = true
619        )
620    )]
621    async fn tool_move_tag(
622        &self,
623        Parameters(args): Parameters<MoveTagArgs>,
624    ) -> Result<Json<TagOutcome>, McpError> {
625        let out = things_move_tag(self.state.clone(), args)
626            .await
627            .map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
628        Ok(Json(out))
629    }
630}
631
632#[tool_handler]
633impl ServerHandler for ThingsServer {
634    fn get_info(&self) -> ServerInfo {
635        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
636            .with_server_info(Implementation::new("things-mcp", env!("CARGO_PKG_VERSION")))
637    }
638}