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,318 @@
1
+ import urllib.parse
2
+ import webbrowser
3
+ import things
4
+ import subprocess
5
+ import platform
6
+ import random
7
+ import time
8
+ import logging
9
+ import json
10
+ from typing import Optional, Dict, Any, Union, Callable
11
+ from .utils import app_state, circuit_breaker, dead_letter_queue, rate_limiter, is_things_running
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ def launch_things() -> bool:
16
+ """Launch Things app if not already running.
17
+
18
+ Returns:
19
+ bool: True if successful, False otherwise
20
+ """
21
+ try:
22
+ if is_things_running():
23
+ return True
24
+
25
+ result = subprocess.run(
26
+ ['open', '-a', 'Things3'],
27
+ capture_output=True,
28
+ text=True,
29
+ check=False
30
+ )
31
+
32
+ # Give Things time to launch
33
+ time.sleep(2)
34
+
35
+ return is_things_running()
36
+ except Exception as e:
37
+ logger.error(f"Error launching Things: {str(e)}")
38
+ return False
39
+
40
+ def execute_url(url: str) -> bool:
41
+ """Execute a Things URL by opening it in the default browser.
42
+ Returns True if successful, False otherwise.
43
+ """
44
+ # Ensure any + signs in the URL are replaced with %20
45
+ url = url.replace("+", "%20")
46
+
47
+ # Log the URL for debugging
48
+ logger.debug(f"Executing URL: {url}")
49
+
50
+ # Apply rate limiting
51
+ rate_limiter.wait_if_needed()
52
+
53
+ # Check if circuit breaker allows the operation
54
+ if not circuit_breaker.allow_operation():
55
+ logger.warning("Circuit breaker is open, blocking operation")
56
+ return False
57
+
58
+ try:
59
+ # Check if Things is running, attempt to launch if not
60
+ if not is_things_running():
61
+ logger.info("Things is not running, attempting to launch")
62
+ if not launch_things():
63
+ logger.error("Failed to launch Things")
64
+ circuit_breaker.record_failure()
65
+ return False
66
+
67
+ # Execute the URL
68
+ result = webbrowser.open(url)
69
+
70
+ if not result:
71
+ circuit_breaker.record_failure()
72
+ logger.error(f"Failed to open URL: {url}")
73
+ return False
74
+
75
+ # Add a small delay to allow Things time to process the command
76
+ # Add jitter to prevent thundering herd problem
77
+ delay = 0.5 + random.uniform(0, 0.2) # 0.5-0.7 seconds
78
+ time.sleep(delay)
79
+
80
+ circuit_breaker.record_success()
81
+ return True
82
+ except Exception as e:
83
+ logger.error(f"Failed to execute URL: {url}, Error: {str(e)}")
84
+ circuit_breaker.record_failure()
85
+ return False
86
+
87
+
88
+ def execute_xcallback_url(action: str, params: Dict[str, Any]) -> bool:
89
+ """Execute a Things X-Callback-URL.
90
+
91
+ Args:
92
+ action: The X-Callback action to perform
93
+ params: Parameters for the action
94
+
95
+ Returns:
96
+ bool: True if successful, False otherwise
97
+ """
98
+ # The correct format for Things URLs (no 'x-callback-url/' prefix)
99
+ base_url = "things:///"
100
+
101
+ # Add callback parameters, but only if we need them
102
+ # For now, avoid using callbacks since we don't have a handler for them
103
+ callback_params = params.copy()
104
+
105
+ # Don't add callback URLs - this avoids the "no application set to open URL" error
106
+ # If we need callbacks later, we'd need to register a URL handler for our app
107
+
108
+ # Construct URL - action is part of the path (not a separate query parameter)
109
+ url = f"{base_url}{action}?{urllib.parse.urlencode(callback_params)}"
110
+
111
+ # Log the URL for debugging
112
+ logger.debug(f"Executing URL: {url}")
113
+
114
+ return execute_url(url)
115
+
116
+ def construct_url(command: str, params: Dict[str, Any]) -> str:
117
+ """Construct a Things URL from command and parameters."""
118
+ # Pre-process all string parameters to replace any + signs with spaces
119
+ cleaned_params = {}
120
+ for key, value in params.items():
121
+ if isinstance(value, str):
122
+ # Replace any + signs with spaces in the original input
123
+ cleaned_params[key] = value.replace("+", " ")
124
+ else:
125
+ cleaned_params[key] = value
126
+
127
+ # Use the cleaned params from now on
128
+ params = cleaned_params
129
+
130
+ # Start with base URL
131
+ url = f"things:///{command}"
132
+
133
+ # Get authentication token if needed - applies to all commands to ensure reliability
134
+ try:
135
+ # Import here to avoid circular imports
136
+ from . import config
137
+
138
+ # Get token from config system
139
+ token = config.get_things_auth_token()
140
+
141
+ if token:
142
+ # Add token to all params for consistent behavior
143
+ params['auth-token'] = token
144
+ logger.debug(f"Auth token from config used for {command} operation")
145
+ else:
146
+ logger.warning(f"No Things auth token found in config. URL may not work without a token.")
147
+ # Note: We continue without a token, which may cause the operation to fail
148
+ except Exception as e:
149
+ logger.error(f"Error getting auth token: {str(e)}")
150
+ # Continue without token - the operation may fail
151
+
152
+ # Disable JSON API for now as it's causing formatting issues
153
+ # JSON API is currently experimental and unreliable
154
+ # We'll use the standard URL scheme instead which is more reliable
155
+ use_json_api = False
156
+
157
+ if False and command in ['add'] and use_json_api:
158
+ # This code is disabled but kept for reference
159
+ logger.info("JSON API is currently disabled due to formatting issues")
160
+ pass
161
+
162
+ # Standard URL scheme encoding
163
+ if params:
164
+ encoded_params = []
165
+ for key, value in params.items():
166
+ if value is None:
167
+ continue
168
+ # Handle boolean values
169
+ if isinstance(value, bool):
170
+ value = str(value).lower()
171
+ # Handle lists (for tags, checklist items etc)
172
+ elif isinstance(value, list) and key == 'tags':
173
+ # Important: Tags are sensitive to formatting in Things URL scheme
174
+ # Based on testing, using a simple comma-separated list without spaces works best
175
+ encoded_tags = []
176
+ for tag in value:
177
+ # Ensure tag is properly encoded as string
178
+ tag_str = str(tag).strip()
179
+ if tag_str: # Only add non-empty tags
180
+ encoded_tags.append(tag_str)
181
+
182
+ # Only include non-empty tag lists
183
+ if encoded_tags:
184
+ # Join with commas - Things expects comma-separated tags without spaces between commas
185
+ # Use a simple comma with no spacing for maximum compatibility
186
+ value = ','.join(encoded_tags)
187
+ else:
188
+ # If no valid tags, don't include this parameter
189
+ continue
190
+ # Handle other lists
191
+ elif isinstance(value, list):
192
+ value = ','.join(str(v) for v in value)
193
+
194
+ # Ensure proper encoding of the value - use quote_plus to handle spaces correctly
195
+ # Then replace + with %20 to ensure Things handles spaces correctly
196
+ encoded_value = urllib.parse.quote(str(value), safe='')
197
+ # Replace + with %20 for better compatibility with Things
198
+ encoded_value = encoded_value.replace('+', '%20')
199
+ encoded_params.append(f"{key}={encoded_value}")
200
+
201
+ url += "?" + "&".join(encoded_params)
202
+
203
+ return url
204
+
205
+
206
+ def should_use_json_api() -> bool:
207
+ """Determine if the JSON API should be used based on Things version."""
208
+ from .utils import detect_things_version
209
+
210
+ version = detect_things_version()
211
+ if not version:
212
+ # Default to using JSON API if version can't be determined
213
+ return True
214
+
215
+ try:
216
+ # Parse version string (e.g., '3.15.4')
217
+ major, minor, _ = map(int, version.split('.'))
218
+
219
+ # JSON API is available in Things 3.4+
220
+ return major > 3 or (major == 3 and minor >= 4)
221
+ except Exception:
222
+ # Default to standard URL scheme if version parsing fails
223
+ return False
224
+
225
+ def add_todo(title: str, notes: Optional[str] = None, when: Optional[str] = None,
226
+ deadline: Optional[str] = None, tags: Optional[list[str]] = None,
227
+ checklist_items: Optional[list[str]] = None, list_id: Optional[str] = None,
228
+ list_title: Optional[str] = None, heading: Optional[str] = None,
229
+ completed: Optional[bool] = None) -> str:
230
+ """Construct URL to add a new todo."""
231
+ params = {
232
+ 'title': title,
233
+ 'notes': notes,
234
+ 'when': when,
235
+ 'deadline': deadline,
236
+ 'checklist-items': '\n'.join(checklist_items) if checklist_items else None,
237
+ 'list-id': list_id,
238
+ 'list': list_title,
239
+ 'heading': heading,
240
+ 'completed': completed
241
+ }
242
+
243
+ # Handle tags separately since they need to be comma-separated
244
+ if tags:
245
+ params['tags'] = ','.join(tags)
246
+ return construct_url('add', {k: v for k, v in params.items() if v is not None})
247
+
248
+ def add_project(title: str, notes: Optional[str] = None, when: Optional[str] = None,
249
+ deadline: Optional[str] = None, tags: Optional[list[str]] = None,
250
+ area_id: Optional[str] = None, area_title: Optional[str] = None,
251
+ todos: Optional[list[str]] = None) -> str:
252
+ """Construct URL to add a new project."""
253
+ params = {
254
+ 'title': title,
255
+ 'notes': notes,
256
+ 'when': when,
257
+ 'deadline': deadline,
258
+ 'area-id': area_id,
259
+ 'area': area_title,
260
+ # Change todos to be newline separated
261
+ 'to-dos': '\n'.join(todos) if todos else None
262
+ }
263
+
264
+ # Handle tags separately since they need to be comma-separated
265
+ if tags:
266
+ params['tags'] = ','.join(tags)
267
+
268
+ return construct_url('add-project', {k: v for k, v in params.items() if v is not None})
269
+
270
+ def update_todo(id: str, title: Optional[str] = None, notes: Optional[str] = None,
271
+ when: Optional[str] = None, deadline: Optional[str] = None,
272
+ tags: Optional[Union[list[str], str]] = None, add_tags: Optional[Union[list[str], str]] = None,
273
+ checklist_items: Optional[list[str]] = None,
274
+ completed: Optional[bool] = None, canceled: Optional[bool] = None) -> str:
275
+ """Construct URL to update an existing todo."""
276
+ params = {
277
+ 'id': id,
278
+ 'title': title,
279
+ 'notes': notes,
280
+ 'when': when,
281
+ 'deadline': deadline,
282
+ 'tags': tags,
283
+ 'add-tags': add_tags, # Support for adding tags without replacing existing ones
284
+ 'checklist-items': '\n'.join(checklist_items) if checklist_items else None,
285
+ 'completed': completed,
286
+ 'canceled': canceled
287
+ }
288
+ return construct_url('update', {k: v for k, v in params.items() if v is not None})
289
+
290
+ def update_project(id: str, title: Optional[str] = None, notes: Optional[str] = None,
291
+ when: Optional[str] = None, deadline: Optional[str] = None,
292
+ tags: Optional[list[str]] = None, completed: Optional[bool] = None,
293
+ canceled: Optional[bool] = None) -> str:
294
+ """Construct URL to update an existing project."""
295
+ params = {
296
+ 'id': id,
297
+ 'title': title,
298
+ 'notes': notes,
299
+ 'when': when,
300
+ 'deadline': deadline,
301
+ 'tags': tags,
302
+ 'completed': completed,
303
+ 'canceled': canceled
304
+ }
305
+ return construct_url('update-project', {k: v for k, v in params.items() if v is not None})
306
+
307
+ def show(id: str, query: Optional[str] = None, filter_tags: Optional[list[str]] = None) -> str:
308
+ """Construct URL to show a specific item or list."""
309
+ params = {
310
+ 'id': id,
311
+ 'query': query,
312
+ 'filter': filter_tags
313
+ }
314
+ return construct_url('show', {k: v for k, v in params.items() if v is not None})
315
+
316
+ def search(query: str) -> str:
317
+ """Construct URL to perform a search."""
318
+ return construct_url('search', {'query': query})
things_mcp/utils.py ADDED
@@ -0,0 +1,360 @@
1
+ """
2
+ Utility classes and functions for enhancing Things MCP reliability.
3
+ """
4
+ import json
5
+ import time
6
+ import logging
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import urllib.parse
11
+ import mcp.types as types
12
+ from typing import Dict, Any, Optional, Callable, List, Union
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def is_things_running() -> bool:
18
+ """Check if Things app is running.
19
+
20
+ Returns:
21
+ bool: True if Things is running, False otherwise
22
+ """
23
+ try:
24
+ if platform.system() != 'Darwin':
25
+ logger.warning("Things availability check only works on macOS")
26
+ return True
27
+
28
+ result = subprocess.run(
29
+ ['osascript', '-e', 'tell application "System Events" to (name of processes) contains "Things3"'],
30
+ capture_output=True,
31
+ text=True,
32
+ check=False
33
+ )
34
+ is_running = result.stdout.strip().lower() == 'true'
35
+
36
+ # If Things is running, also check if it's responsive
37
+ if is_running:
38
+ # Simple ping to see if Things responds
39
+ ping_result = subprocess.run(
40
+ ['osascript', '-e', 'tell application "Things3" to return name'],
41
+ capture_output=True,
42
+ text=True,
43
+ check=False,
44
+ timeout=2 # 2 second timeout
45
+ )
46
+ is_responsive = ping_result.returncode == 0
47
+
48
+ if not is_responsive:
49
+ logger.warning("Things is running but not responsive")
50
+ return False
51
+
52
+ return is_running
53
+ except subprocess.TimeoutExpired:
54
+ logger.warning("Things app check timed out")
55
+ return False
56
+ except Exception as e:
57
+ logger.error(f"Error checking if Things is running: {str(e)}")
58
+ return False
59
+
60
+ class ThingsAppState:
61
+ """Track and manage Things app state"""
62
+
63
+ def __init__(self):
64
+ self.is_available = False
65
+ self.last_check_time = 0
66
+ self.check_interval = 5 # seconds
67
+ self.update_app_state()
68
+
69
+ def update_app_state(self):
70
+ """Update app availability state"""
71
+ current_time = time.time()
72
+ if current_time - self.last_check_time > self.check_interval:
73
+ self.is_available = is_things_running()
74
+ self.last_check_time = current_time
75
+ return self.is_available
76
+
77
+ def wait_for_app_availability(self, timeout=10):
78
+ """Wait for app to become available within timeout"""
79
+ start_time = time.time()
80
+ while time.time() - start_time < timeout:
81
+ if self.update_app_state():
82
+ return True
83
+ time.sleep(0.5)
84
+ return False
85
+
86
+
87
+ def validate_tool_registration(tools: list[types.Tool]) -> bool:
88
+ """
89
+ Validate that all required Things MCP tools are properly registered.
90
+
91
+ Args:
92
+ tools: List of registered tools
93
+
94
+ Returns:
95
+ bool: True if all required tools are registered, False otherwise
96
+ """
97
+ required_tool_names = [
98
+ "get-inbox", "get-today", "get-upcoming", "get-anytime",
99
+ "get-someday", "get-logbook", "get-trash", "get-todos",
100
+ "get-projects", "get-areas", "get-tags", "get-tagged-items",
101
+ "search-todos", "search-advanced", "get-recent", "add-todo",
102
+ "search-items", "add-project", "update-todo", "update-project", "show-item"
103
+ ]
104
+
105
+ registered_tool_names = [tool.name for tool in tools]
106
+
107
+ # Check if all required tools are registered
108
+ missing_tools = [name for name in required_tool_names if name not in registered_tool_names]
109
+
110
+ if missing_tools:
111
+ logger.error(f"Missing required tool registrations: {missing_tools}")
112
+ return False
113
+
114
+ # Check if all registered tools have proper descriptions and parameters
115
+ for tool in tools:
116
+ if not tool.description or len(tool.description) < 10:
117
+ logger.warning(f"Tool '{tool.name}' has an insufficient description")
118
+
119
+ # Basic parameter validation could be added here
120
+ # This would depend on your tool schema requirements
121
+
122
+ return True
123
+
124
+
125
+ class CircuitBreaker:
126
+ """Circuit breaker to prevent repeated failed attempts"""
127
+
128
+ # Circuit states
129
+ CLOSED = "closed" # Normal operation
130
+ OPEN = "open" # Not allowing operations
131
+ HALF_OPEN = "half-open" # Testing if system has recovered
132
+
133
+ def __init__(self, failure_threshold=5, recovery_timeout=60):
134
+ self.state = self.CLOSED
135
+ self.failure_count = 0
136
+ self.failure_threshold = failure_threshold
137
+ self.recovery_timeout = recovery_timeout
138
+ self.last_failure_time = 0
139
+
140
+ def record_failure(self):
141
+ """Record a failure and potentially open the circuit"""
142
+ self.failure_count += 1
143
+ self.last_failure_time = time.time()
144
+
145
+ if self.failure_count >= self.failure_threshold:
146
+ self.state = self.OPEN
147
+ logger.warning(f"Circuit breaker opened after {self.failure_count} failures")
148
+
149
+ def record_success(self):
150
+ """Record a success and reset the circuit if in half-open state"""
151
+ if self.state == self.HALF_OPEN:
152
+ self.state = self.CLOSED
153
+ self.failure_count = 0
154
+ logger.info("Circuit breaker closed after successful operation")
155
+ elif self.state == self.CLOSED:
156
+ self.failure_count = 0
157
+
158
+ def allow_operation(self):
159
+ """Check if operation should be allowed"""
160
+ if self.state == self.CLOSED:
161
+ return True
162
+
163
+ if self.state == self.OPEN:
164
+ # Check if recovery timeout has elapsed
165
+ if time.time() - self.last_failure_time > self.recovery_timeout:
166
+ self.state = self.HALF_OPEN
167
+ logger.info("Circuit breaker half-open, testing system recovery")
168
+ return True
169
+ return False
170
+
171
+ # Half-open state allows a single test operation
172
+ return True
173
+
174
+
175
+ class DeadLetterQueue:
176
+ """Store persistently failed operations for manual review"""
177
+
178
+ def __init__(self, dlq_file="things_dlq.json"):
179
+ self.dlq_file = dlq_file
180
+ self.queue = self._load_queue()
181
+
182
+ def _load_queue(self):
183
+ """Load persisted queue"""
184
+ try:
185
+ if os.path.exists(self.dlq_file):
186
+ with open(self.dlq_file, 'r') as f:
187
+ return json.load(f)
188
+ return []
189
+ except Exception as e:
190
+ logger.error(f"Error loading DLQ: {str(e)}")
191
+ return []
192
+
193
+ def _save_queue(self):
194
+ """Persist queue to disk"""
195
+ try:
196
+ with open(self.dlq_file, 'w') as f:
197
+ json.dump(self.queue, f)
198
+ except Exception as e:
199
+ logger.error(f"Error saving DLQ: {str(e)}")
200
+
201
+ def add_failed_operation(self, operation, params, error, attempts=1):
202
+ """Add failed operation to dead letter queue"""
203
+ entry = {
204
+ "operation": operation,
205
+ "params": params,
206
+ "error": str(error),
207
+ "attempts": attempts,
208
+ "timestamp": time.time(),
209
+ "added_at": time.strftime("%Y-%m-%d %H:%M:%S")
210
+ }
211
+
212
+ self.queue.append(entry)
213
+ self._save_queue()
214
+ logger.warning(f"Added to DLQ: {operation} with error: {str(error)}")
215
+
216
+ def retry_all(self):
217
+ """Attempt to retry all operations in the DLQ"""
218
+ if not self.queue:
219
+ return {"success": True, "retried": 0, "failed": 0}
220
+
221
+ from .url_scheme import construct_url, execute_url
222
+
223
+ success_count = 0
224
+ failure_count = 0
225
+ remaining_queue = []
226
+
227
+ for entry in self.queue:
228
+ try:
229
+ url = construct_url(entry["operation"], entry["params"])
230
+ result = retry_operation(lambda: execute_url(url))
231
+
232
+ if result:
233
+ success_count += 1
234
+ else:
235
+ entry["attempts"] += 1
236
+ remaining_queue.append(entry)
237
+ failure_count += 1
238
+ except Exception:
239
+ entry["attempts"] += 1
240
+ remaining_queue.append(entry)
241
+ failure_count += 1
242
+
243
+ self.queue = remaining_queue
244
+ self._save_queue()
245
+
246
+ return {
247
+ "success": failure_count == 0,
248
+ "retried": success_count + failure_count,
249
+ "failed": failure_count
250
+ }
251
+
252
+
253
+ class RateLimiter:
254
+ """Intelligent rate limiter for Things operations"""
255
+
256
+ def __init__(self, operations_per_minute=30):
257
+ self.operations_per_minute = operations_per_minute
258
+ self.operation_interval = 60 / operations_per_minute
259
+ self.last_operation_time = 0
260
+
261
+ def wait_if_needed(self):
262
+ """Wait if necessary to maintain rate limit"""
263
+ current_time = time.time()
264
+ time_since_last = current_time - self.last_operation_time
265
+
266
+ if time_since_last < self.operation_interval:
267
+ # Need to wait
268
+ wait_time = self.operation_interval - time_since_last
269
+ time.sleep(wait_time)
270
+
271
+ self.last_operation_time = time.time()
272
+
273
+ def __call__(self, func):
274
+ """Decorator to rate limit a function"""
275
+ def wrapper(*args, **kwargs):
276
+ self.wait_if_needed()
277
+ return func(*args, **kwargs)
278
+ return wrapper
279
+
280
+
281
+ def get_auth_token() -> Optional[str]:
282
+ """Get the Things authentication token from various possible sources.
283
+
284
+ The function tries to get the token from:
285
+ 1. Environment variable THINGS_AUTH_TOKEN
286
+ 2. Local config file
287
+ 3. Hardcoded fallback value
288
+
289
+ Returns:
290
+ str: Authentication token if found, None otherwise
291
+ """
292
+ # Try environment variable first
293
+ token = os.environ.get('THINGS_AUTH_TOKEN')
294
+ if token:
295
+ logger.info("Using Things authentication token from environment variable")
296
+ return token
297
+
298
+ # Try local config file
299
+ try:
300
+ config_path = os.path.expanduser("~/.things_config.json")
301
+ if os.path.exists(config_path):
302
+ with open(config_path, 'r') as f:
303
+ config = json.load(f)
304
+ if 'auth_token' in config and config['auth_token']:
305
+ logger.info("Using Things authentication token from config file")
306
+ return config['auth_token']
307
+ except Exception as e:
308
+ logger.warning(f"Failed to read auth token from config file: {str(e)}")
309
+
310
+ # No token found from dynamic sources
311
+ logger.warning("No Things authentication token found in environment or config")
312
+ return None
313
+
314
+
315
+ def detect_things_version():
316
+ """Detect installed Things version"""
317
+ try:
318
+ # Use AppleScript to get Things version
319
+ script = 'tell application "Things3" to return version'
320
+ result = subprocess.run(
321
+ ['osascript', '-e', script],
322
+ capture_output=True,
323
+ text=True,
324
+ check=False
325
+ )
326
+
327
+ if result.returncode == 0 and result.stdout.strip():
328
+ return result.stdout.strip()
329
+ except Exception as e:
330
+ logger.error(f"Error detecting Things version: {str(e)}")
331
+
332
+ return None
333
+
334
+
335
+ def validate_tool_registration(tool_list):
336
+ """Validate that all tools are properly registered"""
337
+ required_tools = [
338
+ "get-inbox", "get-today", "get-upcoming", "get-anytime",
339
+ "get-someday", "get-logbook", "get-trash", "get-todos",
340
+ "get-projects", "get-areas", "get-tags", "get-tagged-items",
341
+ "search-todos", "search-advanced", "get-recent", "add-todo",
342
+ "add-project", "update-todo", "update-project", "show-item"
343
+ ]
344
+
345
+ tool_names = [t.name for t in tool_list]
346
+ missing_tools = [tool for tool in required_tools if tool not in tool_names]
347
+
348
+ if missing_tools:
349
+ logger.error(f"Missing tool registrations: {missing_tools}")
350
+ return False
351
+
352
+ logger.info(f"All {len(tool_list)} tools are properly registered")
353
+ return True
354
+
355
+
356
+ # Create global instances
357
+ app_state = ThingsAppState()
358
+ circuit_breaker = CircuitBreaker()
359
+ dead_letter_queue = DeadLetterQueue()
360
+ rate_limiter = RateLimiter()