1use 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}