use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, Json, ServerHandler};
use crate::core::types::{AreaList, MaybeProject, MaybeTodo, ProjectList, TodoList};
use crate::tools::todos::{
things_add_todo, things_assign_tag, things_cancel_todo, things_complete_todo,
things_get_todo, things_move_todo, things_unassign_tag, things_update_todo,
AddTodoArgs, GetTodoArgs, MoveTodoArgs, StatusChangeArgs, TagAssignmentArgs,
UpdateTodoArgs,
};
use crate::core::writer::outcome::WriteOutcome;
use crate::tools::projects::{
things_add_project, things_get_project, things_update_project,
AddProjectArgs, GetProjectArgs, UpdateProjectArgs,
};
use crate::tools::bulk::{things_bulk_json, BulkJsonArgs};
use crate::tools::search::{things_search, SearchArgs};
use crate::state::AppState;
use crate::tools::lists::{
things_list_anytime, things_list_areas, things_list_by_tag, things_list_inbox,
things_list_logbook, things_list_projects, things_list_someday,
things_list_today, things_list_trash, things_list_upcoming, ListAnytimeArgs,
ListAreasArgs, ListByTagArgs, ListInboxArgs, ListLogbookArgs, ListProjectsArgs,
ListSomedayArgs, ListTodayArgs, ListTrashArgs, ListUpcomingArgs,
};
use crate::core::applescript::admin::TagOutcome;
use crate::core::reader::tags::TagListing;
use crate::tools::tags::{
things_create_tag, things_delete_tag, things_list_tags, things_merge_tags,
things_move_tag, things_rename_tag,
CreateTagArgs, DeleteTagArgs, ListTagsArgs, MergeTagsArgs, MoveTagArgs, RenameTagArgs,
};
#[derive(Clone)]
pub struct ThingsServer {
pub state: AppState,
}
#[tool_router]
impl ThingsServer {
pub fn new(state: AppState) -> Self {
Self { state }
}
#[tool(
name = "things_list_inbox",
description = "Return to-dos in the Things Inbox. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_inbox(
&self,
Parameters(args): Parameters<ListInboxArgs>,
) -> Result<Json<TodoList>, McpError> {
let state = self.state.clone();
let rows = things_list_inbox(state, args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_today",
description = "Return to-dos scheduled for today (start = Anytime with startDate ≤ today). Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_today(
&self,
Parameters(args): Parameters<ListTodayArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_today(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_upcoming",
description = "Return scheduled or deadlined to-dos in the future. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_upcoming(
&self,
Parameters(args): Parameters<ListUpcomingArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_upcoming(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_anytime",
description = "Return Anytime to-dos (start=Anytime, no scheduled date). Optionally filter by area. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_anytime(
&self,
Parameters(args): Parameters<ListAnytimeArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_anytime(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_someday",
description = "Return Someday to-dos (start = Someday). Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_someday(
&self,
Parameters(args): Parameters<ListSomedayArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_someday(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_logbook",
description = "Return completed or canceled to-dos, newest first. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_logbook(
&self,
Parameters(args): Parameters<ListLogbookArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_logbook(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_trash",
description = "Return trashed to-dos, newest first. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_trash(
&self,
Parameters(args): Parameters<ListTrashArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_trash(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_areas",
description = "Return all areas, ordered by display index. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_areas(
&self,
Parameters(args): Parameters<ListAreasArgs>,
) -> Result<Json<AreaList>, McpError> {
let rows = things_list_areas(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_projects",
description = "Return projects, optionally restricted to a single area and/or a status filter (open/done/all). Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_projects(
&self,
Parameters(args): Parameters<ListProjectsArgs>,
) -> Result<Json<ProjectList>, McpError> {
let rows = things_list_projects(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_list_tags",
description = "Return all tags. `flat` is the every-tag list; `roots` is a tree of `TagNode`s rooted at parentless tags. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_tags(
&self,
Parameters(args): Parameters<ListTagsArgs>,
) -> Result<Json<TagListing>, McpError> {
let listing = things_list_tags(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(listing))
}
#[tool(
name = "things_list_by_tag",
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.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_list_by_tag(
&self,
Parameters(args): Parameters<ListByTagArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_list_by_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_get_todo",
description = "Return a single to-do with notes, checklist, tags, and a repeating-template flag. Returns null if not found. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_get_todo(
&self,
Parameters(args): Parameters<GetTodoArgs>,
) -> Result<Json<MaybeTodo>, McpError> {
let res = things_get_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(res))
}
#[tool(
name = "things_get_project",
description = "Return a single project with its child to-dos and headings. Returns null if not found. Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_get_project(
&self,
Parameters(args): Parameters<GetProjectArgs>,
) -> Result<Json<MaybeProject>, McpError> {
let res = things_get_project(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(res))
}
#[tool(
name = "things_search",
description = "Search to-dos by free text (title + notes) and structured filters (tags, area, project, status, deadline range, scheduled range). Read-only.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn tool_search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<Json<TodoList>, McpError> {
let rows = things_search(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(rows))
}
#[tool(
name = "things_add_todo",
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.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_add_todo(
&self,
Parameters(args): Parameters<AddTodoArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_add_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_add_project",
description = "Create a new project in Things, optionally with initial headings nested inside. Returns a WriteOutcome with the new id once verified.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_add_project(
&self,
Parameters(args): Parameters<AddProjectArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_add_project(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_update_todo",
description = "Update an existing to-do's title, notes, scheduling, tags, list, or status. Only populated fields are sent. Requires the Things auth-token.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_update_todo(
&self,
Parameters(args): Parameters<UpdateTodoArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_update_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_update_project",
description = "Update an existing project's title, notes, scheduling, tags, parent area, or status. Only populated fields are sent. Requires the Things auth-token.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_update_project(
&self,
Parameters(args): Parameters<UpdateProjectArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_update_project(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_complete_todo",
description = "Mark a to-do as completed. Idempotent: re-completing has no further effect. Requires the Things auth-token.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
async fn tool_complete_todo(
&self,
Parameters(args): Parameters<StatusChangeArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_complete_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_cancel_todo",
description = "Mark a to-do as canceled (distinct from completed in Things). Idempotent. Requires the Things auth-token.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
async fn tool_cancel_todo(
&self,
Parameters(args): Parameters<StatusChangeArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_cancel_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_move_todo",
description = "Move a to-do under a project, area, or to the Inbox (when list_id is omitted). Requires the Things auth-token.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_move_todo(
&self,
Parameters(args): Parameters<MoveTodoArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_move_todo(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_bulk_json",
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.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_bulk_json(
&self,
Parameters(args): Parameters<BulkJsonArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_bulk_json(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_assign_tag",
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).",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
async fn tool_assign_tag(
&self,
Parameters(args): Parameters<TagAssignmentArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_assign_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_unassign_tag",
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).",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
async fn tool_unassign_tag(
&self,
Parameters(args): Parameters<TagAssignmentArgs>,
) -> Result<Json<WriteOutcome>, McpError> {
let out = things_unassign_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_create_tag",
description = "Create a new tag. Optionally nest it under an existing parent tag by name. Runs via AppleScript (`osascript`).",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_create_tag(
&self,
Parameters(args): Parameters<CreateTagArgs>,
) -> Result<Json<TagOutcome>, McpError> {
let out = things_create_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_rename_tag",
description = "Rename an existing tag globally. Every to-do that carried the old name will surface the new name. Runs via AppleScript.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_rename_tag(
&self,
Parameters(args): Parameters<RenameTagArgs>,
) -> Result<Json<TagOutcome>, McpError> {
let out = things_rename_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_merge_tags",
description = "Reassign every to-do tagged `source` to also carry `target`, then delete `source`. Source and target must differ. Runs via AppleScript.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_merge_tags(
&self,
Parameters(args): Parameters<MergeTagsArgs>,
) -> Result<Json<TagOutcome>, McpError> {
let out = things_merge_tags(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_delete_tag",
description = "Delete a tag globally. To-dos that carry the tag stay; only the tag itself is removed. Runs via AppleScript.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_delete_tag(
&self,
Parameters(args): Parameters<DeleteTagArgs>,
) -> Result<Json<TagOutcome>, McpError> {
let out = things_delete_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
#[tool(
name = "things_move_tag",
description = "Move a tag under a new parent tag (or to the root when `new_parent` is omitted/null). Runs via AppleScript.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn tool_move_tag(
&self,
Parameters(args): Parameters<MoveTagArgs>,
) -> Result<Json<TagOutcome>, McpError> {
let out = things_move_tag(self.state.clone(), args)
.await
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))?;
Ok(Json(out))
}
}
#[tool_handler]
impl ServerHandler for ThingsServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("things-mcp", env!("CARGO_PKG_VERSION")))
}
}