iflow-mcp_excelsier-things3-enhanced-mcp 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simplified URL scheme implementation for Things.
4
+ Based on https://culturedcode.com/things/support/articles/2803573/
5
+ """
6
+ import urllib.parse
7
+ import webbrowser
8
+ import subprocess
9
+ import time
10
+ import logging
11
+ from typing import Optional, Dict, Any, List
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ def launch_things() -> bool:
16
+ """Launch Things app if not already running."""
17
+ try:
18
+ # Check if running
19
+ result = subprocess.run(
20
+ ['osascript', '-e', 'tell application "System Events" to (name of processes) contains "Things3"'],
21
+ capture_output=True,
22
+ text=True,
23
+ check=False
24
+ )
25
+
26
+ if result.stdout.strip().lower() == 'true':
27
+ return True
28
+
29
+ # Launch Things
30
+ subprocess.run(['open', '-a', 'Things3'], capture_output=True, check=False)
31
+ time.sleep(2) # Give it time to start
32
+ return True
33
+ except Exception as e:
34
+ logger.error(f"Error launching Things: {str(e)}")
35
+ return False
36
+
37
+ def execute_url(url: str) -> bool:
38
+ """Execute a Things URL."""
39
+ try:
40
+ logger.debug(f"Executing URL: {url}")
41
+
42
+ # Ensure Things is running
43
+ launch_things()
44
+
45
+ # Execute the URL
46
+ result = webbrowser.open(url)
47
+
48
+ # Small delay to let Things process
49
+ time.sleep(0.5)
50
+
51
+ return result
52
+ except Exception as e:
53
+ logger.error(f"Failed to execute URL: {str(e)}")
54
+ return False
55
+
56
+ def construct_url(command: str, params: Dict[str, Any]) -> str:
57
+ """Construct a Things URL from command and parameters."""
58
+ # Start with base URL
59
+ url = f"things:///{command}"
60
+
61
+ # Get authentication token if available
62
+ try:
63
+ from . import config
64
+ token = config.get_things_auth_token()
65
+ if token:
66
+ params['auth-token'] = token
67
+ logger.debug(f"Added authentication token to {command} URL")
68
+ else:
69
+ logger.warning(f"No authentication token configured for {command} URL")
70
+ except Exception as e:
71
+ logger.error(f"Failed to get authentication token: {str(e)}")
72
+ # Try environment variable as fallback
73
+ import os
74
+ token = os.environ.get('THINGS_AUTH_TOKEN')
75
+ if token:
76
+ params['auth-token'] = token
77
+ logger.debug(f"Using authentication token from environment variable")
78
+
79
+ # Filter out None values
80
+ params = {k: v for k, v in params.items() if v is not None}
81
+
82
+ if params:
83
+ encoded_params = []
84
+ for key, value in params.items():
85
+ if isinstance(value, bool):
86
+ value = str(value).lower()
87
+ elif isinstance(value, list):
88
+ if key == 'tags':
89
+ # Tags should be comma-separated
90
+ value = ','.join(str(v) for v in value)
91
+ elif key == 'checklist-items':
92
+ # Checklist items should be newline-separated
93
+ value = '\n'.join(str(v) for v in value)
94
+ else:
95
+ value = ','.join(str(v) for v in value)
96
+
97
+ # URL encode the value
98
+ encoded_value = urllib.parse.quote(str(value), safe='')
99
+ encoded_params.append(f"{key}={encoded_value}")
100
+
101
+ url += "?" + "&".join(encoded_params)
102
+
103
+ return url
104
+
105
+ # URL scheme functions based on Things documentation
106
+
107
+ def add_todo(
108
+ title: str,
109
+ notes: Optional[str] = None,
110
+ when: Optional[str] = None,
111
+ deadline: Optional[str] = None,
112
+ tags: Optional[List[str]] = None,
113
+ checklist_items: Optional[List[str]] = None,
114
+ list_id: Optional[str] = None,
115
+ list: Optional[str] = None, # Project/Area name
116
+ heading: Optional[str] = None,
117
+ completed: Optional[bool] = None
118
+ ) -> bool:
119
+ """Add a new todo using Things URL scheme."""
120
+ params = {
121
+ 'title': title,
122
+ 'notes': notes,
123
+ 'when': when,
124
+ 'deadline': deadline,
125
+ 'tags': tags,
126
+ 'checklist-items': checklist_items,
127
+ 'list-id': list_id,
128
+ 'list': list, # This is the project/area name
129
+ 'heading': heading,
130
+ 'completed': completed
131
+ }
132
+
133
+ url = construct_url('add', params)
134
+ return execute_url(url)
135
+
136
+ def add_project(
137
+ title: str,
138
+ notes: Optional[str] = None,
139
+ when: Optional[str] = None,
140
+ deadline: Optional[str] = None,
141
+ tags: Optional[List[str]] = None,
142
+ area_id: Optional[str] = None,
143
+ area: Optional[str] = None, # Area name
144
+ todos: Optional[List[str]] = None
145
+ ) -> bool:
146
+ """Add a new project using Things URL scheme."""
147
+ params = {
148
+ 'title': title,
149
+ 'notes': notes,
150
+ 'when': when,
151
+ 'deadline': deadline,
152
+ 'tags': tags,
153
+ 'area-id': area_id,
154
+ 'area': area,
155
+ 'to-dos': todos # List of todo titles
156
+ }
157
+
158
+ url = construct_url('add-project', params)
159
+ return execute_url(url)
160
+
161
+ def update_todo(
162
+ id: str,
163
+ title: Optional[str] = None,
164
+ notes: Optional[str] = None,
165
+ when: Optional[str] = None,
166
+ deadline: Optional[str] = None,
167
+ tags: Optional[List[str]] = None,
168
+ add_tags: Optional[List[str]] = None,
169
+ completed: Optional[bool] = None,
170
+ canceled: Optional[bool] = None
171
+ ) -> bool:
172
+ """Update an existing todo."""
173
+ params = {
174
+ 'id': id,
175
+ 'title': title,
176
+ 'notes': notes,
177
+ 'when': when,
178
+ 'deadline': deadline,
179
+ 'tags': tags,
180
+ 'add-tags': add_tags,
181
+ 'completed': completed,
182
+ 'canceled': canceled
183
+ }
184
+
185
+ url = construct_url('update', params)
186
+ return execute_url(url)
187
+
188
+ def update_project(
189
+ id: str,
190
+ title: Optional[str] = None,
191
+ notes: Optional[str] = None,
192
+ when: Optional[str] = None,
193
+ deadline: Optional[str] = None,
194
+ tags: Optional[List[str]] = None,
195
+ completed: Optional[bool] = None,
196
+ canceled: Optional[bool] = None
197
+ ) -> bool:
198
+ """Update an existing project."""
199
+ params = {
200
+ 'id': id,
201
+ 'title': title,
202
+ 'notes': notes,
203
+ 'when': when,
204
+ 'deadline': deadline,
205
+ 'tags': tags,
206
+ 'completed': completed,
207
+ 'canceled': canceled
208
+ }
209
+
210
+ url = construct_url('update-project', params)
211
+ return execute_url(url)
212
+
213
+ def show(
214
+ id: str,
215
+ query: Optional[str] = None,
216
+ filter: Optional[List[str]] = None
217
+ ) -> bool:
218
+ """Show a specific item or list in Things."""
219
+ params = {
220
+ 'id': id,
221
+ 'query': query,
222
+ 'filter': filter
223
+ }
224
+
225
+ url = construct_url('show', params)
226
+ return execute_url(url)
227
+
228
+ def search(query: str) -> bool:
229
+ """Search for items in Things."""
230
+ return show(id='search', query=query)
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tag handler for Things MCP.
4
+ Ensures tags exist before applying them.
5
+ """
6
+ import subprocess
7
+ import logging
8
+ from typing import List, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ def ensure_tags_exist(tags: List[str]) -> bool:
13
+ """
14
+ Ensure all tags exist in Things before using them.
15
+ Creates missing tags using AppleScript.
16
+
17
+ Args:
18
+ tags: List of tag names to ensure exist
19
+
20
+ Returns:
21
+ bool: True if all tags exist or were created successfully
22
+ """
23
+ if not tags:
24
+ return True
25
+
26
+ try:
27
+ # Build AppleScript to check and create tags
28
+ script_lines = ['tell application "Things3"']
29
+
30
+ for tag in tags:
31
+ # Escape quotes in tag name
32
+ escaped_tag = tag.replace('"', '\\"')
33
+
34
+ # Check if tag exists, create if not
35
+ script_lines.extend([
36
+ f' set tagName to "{escaped_tag}"',
37
+ ' set tagExists to false',
38
+ ' repeat with t in tags',
39
+ ' if name of t is tagName then',
40
+ ' set tagExists to true',
41
+ ' exit repeat',
42
+ ' end if',
43
+ ' end repeat',
44
+ ' if not tagExists then',
45
+ ' try',
46
+ ' make new tag with properties {name:tagName}',
47
+ f' log "Created tag: " & tagName',
48
+ ' on error',
49
+ f' log "Failed to create tag: " & tagName',
50
+ ' end try',
51
+ ' end if'
52
+ ])
53
+
54
+ script_lines.append('end tell')
55
+ script = '\n'.join(script_lines)
56
+
57
+ # Execute the AppleScript
58
+ result = subprocess.run(
59
+ ['osascript', '-e', script],
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=10
63
+ )
64
+
65
+ if result.returncode != 0:
66
+ logger.error(f"Failed to ensure tags exist: {result.stderr}")
67
+ return False
68
+
69
+ logger.info(f"Ensured tags exist: {', '.join(tags)}")
70
+ return True
71
+
72
+ except subprocess.TimeoutExpired:
73
+ logger.error("Timeout while ensuring tags exist")
74
+ return False
75
+ except Exception as e:
76
+ logger.error(f"Error ensuring tags exist: {str(e)}")
77
+ return False
78
+
79
+ def get_existing_tags() -> List[str]:
80
+ """
81
+ Get list of all existing tags in Things.
82
+
83
+ Returns:
84
+ List[str]: List of tag names
85
+ """
86
+ try:
87
+ script = '''tell application "Things3"
88
+ set tagList to {}
89
+ repeat with t in tags
90
+ set end of tagList to name of t
91
+ end repeat
92
+ return tagList
93
+ end tell'''
94
+
95
+ result = subprocess.run(
96
+ ['osascript', '-e', script],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=5
100
+ )
101
+
102
+ if result.returncode == 0 and result.stdout:
103
+ # Parse the output (comma-separated list)
104
+ tags = [tag.strip() for tag in result.stdout.strip().split(',')]
105
+ return tags
106
+
107
+ return []
108
+
109
+ except Exception as e:
110
+ logger.error(f"Error getting existing tags: {str(e)}")
111
+ return []
@@ -0,0 +1,106 @@
1
+ from typing import Any, List, Optional, Dict
2
+ import logging
3
+ import asyncio
4
+ import sys
5
+ import re
6
+ import traceback
7
+ from mcp.server.models import InitializationOptions
8
+ import mcp.types as types
9
+ from mcp.server import NotificationOptions, Server
10
+ import mcp.server.stdio
11
+
12
+ # Import our direct MCP tool definitions for Windsurf compatibility
13
+ from mcp_tools import get_mcp_tools_list
14
+ from handlers import handle_tool_call
15
+ from utils import validate_tool_registration, app_state
16
+ import url_scheme
17
+
18
+ # Configure logging
19
+ logging.basicConfig(level=logging.DEBUG)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ server = Server("things")
23
+
24
+ @server.list_tools()
25
+ async def handle_list_tools() -> list[types.Tool]:
26
+ """List available tools for Things integration with Windsurf compatibility."""
27
+ return get_mcp_tools_list()
28
+
29
+ @server.call_tool()
30
+ async def handle_call_tool(
31
+ name: str, arguments: dict | None
32
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
33
+ """Handle tool execution requests with Windsurf compatibility."""
34
+ try:
35
+ # Handle both prefixed and non-prefixed tool names consistently
36
+ # If name has mcp2_ prefix, remove it for handler compatibility
37
+ # If name doesn't have prefix, use it directly
38
+
39
+ original_name = name
40
+ base_name = name
41
+
42
+ # Check if the name has the 'mcp2_' prefix and remove it if present
43
+ if name.startswith("mcp2_"):
44
+ base_name = name[5:] # Remove the 'mcp2_' prefix
45
+ logger.info(f"Received prefixed tool call: {name} -> mapping to {base_name}")
46
+ else:
47
+ # No prefix, check if the name is one of our supported tools
48
+ # This allows both prefixed and direct calls to work
49
+ logger.info(f"Received non-prefixed tool call: {name}")
50
+
51
+ # Log the incoming arguments for debugging
52
+ argument_summary = str(arguments)[:100] + "..." if arguments and len(str(arguments)) > 100 else str(arguments)
53
+ logger.info(f"MCP tool call received: {original_name} (handling as: {base_name}) with arguments: {argument_summary}")
54
+
55
+ # Call the appropriate handler with robust error handling
56
+ try:
57
+ return await handle_tool_call(base_name, arguments)
58
+ except Exception as e:
59
+ error_message = f"Error executing tool {name}: {str(e)}"
60
+ logger.error(error_message)
61
+ logger.error(traceback.format_exc())
62
+ return [types.TextContent(type="text", text=f"⚠️ {error_message}")]
63
+ except Exception as outer_e:
64
+ # Catch-all to prevent server crashes
65
+ logger.error(f"Critical error in tool call handler: {str(outer_e)}")
66
+ logger.error(traceback.format_exc())
67
+ return [types.TextContent(type="text", text=f"⚠️ Critical error: {str(outer_e)}")]
68
+
69
+ async def main():
70
+ # Get our MCP tools with proper naming for Windsurf
71
+ mcp_tools = get_mcp_tools_list()
72
+
73
+ # Log successful registration
74
+ logger.info(f"Registered {len(mcp_tools)} MCP-compatible tools for Things")
75
+
76
+ # Check if Things app is available
77
+ if not app_state.update_app_state():
78
+ logger.warning("Things app is not running at startup. MCP will attempt to launch it when needed.")
79
+ try:
80
+ # Try to launch Things
81
+ if url_scheme.launch_things():
82
+ logger.info("Successfully launched Things app")
83
+ else:
84
+ logger.error("Unable to launch Things app. Some operations may fail.")
85
+ except Exception as e:
86
+ logger.error(f"Error launching Things app: {str(e)}")
87
+ else:
88
+ logger.info("Things app is running and ready for operations")
89
+
90
+ # Run the server using stdin/stdout streams
91
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
92
+ await server.run(
93
+ read_stream,
94
+ write_stream,
95
+ InitializationOptions(
96
+ server_name="things",
97
+ server_version="0.1.1", # Updated version with reliability enhancements
98
+ capabilities=server.get_capabilities(
99
+ notification_options=NotificationOptions(),
100
+ experimental_capabilities={},
101
+ ),
102
+ ),
103
+ )
104
+
105
+ if __name__ == "__main__":
106
+ asyncio.run(main())