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.
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/METADATA +444 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/RECORD +20 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/WHEEL +4 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/licenses/LICENSE +24 -0
- things_mcp/__init__.py +5 -0
- things_mcp/applescript_bridge.py +335 -0
- things_mcp/cache.py +240 -0
- things_mcp/config.py +120 -0
- things_mcp/fast_server.py +633 -0
- things_mcp/formatters.py +128 -0
- things_mcp/handlers.py +601 -0
- things_mcp/logging_config.py +218 -0
- things_mcp/mcp_tools.py +465 -0
- things_mcp/simple_server.py +687 -0
- things_mcp/simple_url_scheme.py +230 -0
- things_mcp/tag_handler.py +111 -0
- things_mcp/things_server.py +106 -0
- things_mcp/url_scheme.py +318 -0
- things_mcp/utils.py +360 -0
|
@@ -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())
|