things3-cli 1.0.0

CLI tool for Things 3 with integrated MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
use serde_json::json;
use tempfile::NamedTempFile;
use things3_cli::mcp::{CallToolRequest, ThingsMcpServer};
use things3_core::{test_utils::create_test_database, ThingsConfig, ThingsDatabase};
use uuid::Uuid;

// Test harness for MCP server
struct McpTestHarness {
    server: ThingsMcpServer,
    _temp_file: NamedTempFile,
}

impl McpTestHarness {
    async fn new() -> Self {
        let temp_file = NamedTempFile::new().unwrap();
        let db_path = temp_file.path();
        create_test_database(db_path).await.unwrap();
        let db = ThingsDatabase::new(db_path).await.unwrap();
        let config = ThingsConfig::default();
        let server = ThingsMcpServer::new(std::sync::Arc::new(db), config);

        Self {
            server,
            _temp_file: temp_file,
        }
    }

    async fn call_tool(
        &self,
        name: &str,
        arguments: Option<serde_json::Value>,
    ) -> serde_json::Value {
        let request = CallToolRequest {
            name: name.to_string(),
            arguments: Some(arguments.unwrap_or(json!({}))),
        };

        let result = self.server.call_tool_with_fallback(request).await;

        if result.is_error {
            json!({
                "error": true,
                "content": result.content
            })
        } else {
            let text = result
                .content
                .first()
                .map(|c| match c {
                    things3_cli::mcp::Content::Text { text } => text.clone(),
                })
                .unwrap_or_default();

            serde_json::from_str(&text).unwrap_or(json!({"text": text}))
        }
    }
}

// ============================================================================
// MCP Protocol Tests (10 tests)
// ============================================================================

#[tokio::test]
async fn test_create_task_via_mcp_returns_valid_response() {
    let harness = McpTestHarness::new().await;

    let response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Test Task via MCP"
            })),
        )
        .await;

    assert!(
        response.get("uuid").is_some(),
        "Response should contain UUID"
    );
    assert!(
        response.get("message").is_some(),
        "Response should contain message"
    );
}

#[tokio::test]

async fn test_update_task_via_mcp_returns_success() {
    let harness = McpTestHarness::new().await;

    // First create a task
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task to Update"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();

    // Then update it
    let update_response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": uuid,
                "title": "Updated Task"
            })),
        )
        .await;

    assert!(
        update_response.get("message").is_some(),
        "Update should return success message"
    );
}

#[tokio::test]

async fn test_created_task_can_be_queried() {
    let harness = McpTestHarness::new().await;

    // Create a task
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Queryable Task"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();

    // Verify UUID is valid
    assert!(!uuid.is_empty(), "Created task should have valid UUID");
}

#[tokio::test]

async fn test_validation_failure_error_response() {
    let harness = McpTestHarness::new().await;

    // Try to create task with invalid project UUID
    let response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task with Invalid Project",
                "project_uuid": Uuid::new_v4().to_string()
            })),
        )
        .await;

    assert!(
        response.get("error").is_some() || response.as_str().unwrap_or("").contains("not found"),
        "Should return error for invalid project UUID"
    );
}

#[tokio::test]

async fn test_missing_required_parameter() {
    let harness = McpTestHarness::new().await;

    // Try to create task without title
    let response = harness.call_tool("create_task", Some(json!({}))).await;

    assert!(
        response.get("error").is_some() || response.as_str().unwrap_or("").contains("missing"),
        "Should return error for missing required parameter"
    );
}

#[tokio::test]

async fn test_invalid_parameter_types() {
    let harness = McpTestHarness::new().await;

    // Try to create task with invalid date format
    let response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task with Bad Date",
                "start_date": "not-a-date"
            })),
        )
        .await;

    // Should either fail or ignore the invalid date
    // The exact behavior depends on serde's deserialization
    assert!(response.is_object() || response.is_string());
}

#[tokio::test]

async fn test_null_vs_missing_fields() {
    let harness = McpTestHarness::new().await;

    // Create task with explicit null notes
    let response1 = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task with null notes",
                "notes": null
            })),
        )
        .await;

    assert!(
        response1.get("uuid").is_some(),
        "Should create task with null notes"
    );

    // Create task with missing notes field
    let response2 = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task with missing notes"
            })),
        )
        .await;

    assert!(
        response2.get("uuid").is_some(),
        "Should create task with missing notes"
    );
}

#[tokio::test]

async fn test_create_then_update_workflow() {
    let harness = McpTestHarness::new().await;

    // Create task
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Initial Title",
                "notes": "Initial notes"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();

    // Update task
    let update_response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": uuid,
                "title": "Updated Title",
                "status": "completed"
            })),
        )
        .await;

    assert!(
        update_response.get("message").is_some(),
        "Update should succeed after create"
    );
}

#[tokio::test]

async fn test_create_task_with_all_fields() {
    let harness = McpTestHarness::new().await;

    // First create a project
    let project_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Test Project",
                "task_type": "project"
            })),
        )
        .await;

    let project_uuid = project_response["uuid"].as_str().unwrap();

    // Create task with all fields
    let response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Complete Task",
                "task_type": "to-do",
                "notes": "Task notes",
                "start_date": "2025-01-15",
                "deadline": "2025-01-31",
                "project_uuid": project_uuid,
                "tags": ["work", "urgent"],
                "status": "incomplete"
            })),
        )
        .await;

    assert!(
        response.get("uuid").is_some(),
        "Should create task with all fields"
    );
}

#[tokio::test]

async fn test_update_nonexistent_task_error() {
    let harness = McpTestHarness::new().await;

    let nonexistent_uuid = Uuid::new_v4();
    let response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": nonexistent_uuid.to_string(),
                "title": "Updated Title"
            })),
        )
        .await;

    assert!(
        response.get("error").is_some() || response.as_str().unwrap_or("").contains("not found"),
        "Should return error for nonexistent task"
    );
}

// ============================================================================
// End-to-End Tests (5 tests)
// ============================================================================

#[tokio::test]

async fn test_e2e_create_task_verify_in_inbox() {
    let harness = McpTestHarness::new().await;

    // Create task
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Inbox Task"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();

    // Verify UUID is valid
    assert!(!uuid.is_empty(), "Created task should have valid UUID");
}

#[tokio::test]

async fn test_e2e_create_task_in_project_verify() {
    let harness = McpTestHarness::new().await;

    // Create project
    let project_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Test Project",
                "task_type": "project"
            })),
        )
        .await;

    let project_uuid = project_response["uuid"].as_str().unwrap();

    // Create task in project
    let task_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task in Project",
                "project_uuid": project_uuid
            })),
        )
        .await;

    assert!(
        task_response.get("uuid").is_some(),
        "Should create task in project"
    );
}

#[tokio::test]

async fn test_e2e_update_status_verify_completion() {
    let harness = McpTestHarness::new().await;

    // Create task
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Task to Complete",
                "status": "incomplete"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();

    // Update status to completed
    let update_response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": uuid,
                "status": "completed"
            })),
        )
        .await;

    assert!(
        update_response.get("message").is_some(),
        "Should update task status"
    );
}

#[tokio::test]

async fn test_e2e_create_task_with_tags_search() {
    let harness = McpTestHarness::new().await;

    // Create task with tags
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "Tagged Task",
                "tags": ["important", "work"]
            })),
        )
        .await;

    assert!(
        create_response.get("uuid").is_some(),
        "Should create tagged task"
    );
}

#[tokio::test]

async fn test_e2e_full_crud_cycle() {
    let harness = McpTestHarness::new().await;

    // CREATE
    let create_response = harness
        .call_tool(
            "create_task",
            Some(json!({
                "title": "CRUD Test Task",
                "notes": "Initial notes"
            })),
        )
        .await;

    let uuid = create_response["uuid"].as_str().unwrap();
    assert!(!uuid.is_empty(), "Should create task");

    // UPDATE
    let update_response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": uuid,
                "title": "Updated CRUD Task",
                "notes": "Updated notes"
            })),
        )
        .await;

    assert!(
        update_response.get("message").is_some(),
        "Should update task"
    );

    // DELETE (mark as trashed)
    let delete_response = harness
        .call_tool(
            "update_task",
            Some(json!({
                "uuid": uuid,
                "status": "trashed"
            })),
        )
        .await;

    assert!(
        delete_response.get("message").is_some(),
        "Should mark task as trashed"
    );
}