Skip to content

Commit be334eb

Browse files
committed
mcp + claude integration
1 parent 3f83a7a commit be334eb

8 files changed

Lines changed: 482 additions & 3 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
[package]
22
name = "timesheeps"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "Activity tracker for filling out timesheets"
55
authors = ["you"]
66
edition = "2021"
7+
default-run = "timesheeps"
78

89
[lib]
910
name = "timesheeps_lib"
1011
crate-type = ["staticlib", "cdylib", "rlib"]
1112

13+
[[bin]]
14+
name = "timesheeps-mcp"
15+
path = "src/bin/mcp_server.rs"
16+
1217
[build-dependencies]
1318
tauri-build = { version = "2", features = [] }
1419

src-tauri/src/bin/mcp_server.rs

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
//! Timesheeps MCP server — standalone binary that speaks the Model Context Protocol
2+
//! over stdio. Claude Desktop launches this directly; no Node.js required.
3+
//!
4+
//! DB path: %APPDATA%\app.timesheeps.Timesheeps\timesheeps.db
5+
//! Override: TIMESHEEPS_DB environment variable.
6+
7+
use std::io::{BufRead, BufReader, Write};
8+
9+
use rusqlite::{Connection, OpenFlags};
10+
use serde_json::{json, Value};
11+
12+
// ── Database ──────────────────────────────────────────────────────────────────
13+
14+
fn db_path() -> std::path::PathBuf {
15+
if let Ok(p) = std::env::var("TIMESHEEPS_DB") {
16+
return std::path::PathBuf::from(p);
17+
}
18+
let base = std::env::var("APPDATA")
19+
.map(std::path::PathBuf::from)
20+
.unwrap_or_else(|_| {
21+
let mut p = std::env::var("USERPROFILE")
22+
.map(std::path::PathBuf::from)
23+
.unwrap_or_else(|_| std::path::PathBuf::from("."));
24+
p.push("AppData");
25+
p.push("Roaming");
26+
p
27+
});
28+
base.join("app.timesheeps.Timesheeps").join("timesheeps.db")
29+
}
30+
31+
fn open_db() -> Result<Connection, String> {
32+
let path = db_path();
33+
Connection::open_with_flags(
34+
&path,
35+
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
36+
)
37+
.map_err(|e| format!("Cannot open database at {}: {}", path.display(), e))
38+
}
39+
40+
// ── Formatting helpers ────────────────────────────────────────────────────────
41+
42+
fn today_local() -> String {
43+
chrono::Local::now().format("%Y-%m-%d").to_string()
44+
}
45+
46+
fn fmt_dur(secs: i64) -> String {
47+
let secs = secs.max(0);
48+
if secs < 60 {
49+
return format!("{}s", secs);
50+
}
51+
let h = secs / 3600;
52+
let m = (secs % 3600) / 60;
53+
match (h, m) {
54+
(0, m) => format!("{}m", m),
55+
(h, 0) => format!("{}h", h),
56+
(h, m) => format!("{}h {}m", h, m),
57+
}
58+
}
59+
60+
fn mins_to_hhmm(m: i64) -> String {
61+
format!("{:02}:{:02}", m / 60, m % 60)
62+
}
63+
64+
// ── Tool: activity summary ────────────────────────────────────────────────────
65+
66+
fn get_activity_summary(date: &str) -> Value {
67+
let db = match open_db() {
68+
Ok(c) => c,
69+
Err(e) => return json!({ "error": e }),
70+
};
71+
72+
let mut stmt = match db.prepare(
73+
"SELECT app_name, window_title,
74+
SUM(CAST((julianday(ended_at) - julianday(started_at)) * 86400 AS INTEGER)) AS total_secs
75+
FROM activity_raw
76+
WHERE date(started_at, 'localtime') = ?1
77+
GROUP BY app_name, window_title
78+
ORDER BY total_secs DESC",
79+
) {
80+
Ok(s) => s,
81+
Err(e) => return json!({ "error": e.to_string() }),
82+
};
83+
84+
use std::collections::HashMap;
85+
let mut by_app: HashMap<String, (i64, Vec<Value>)> = HashMap::new();
86+
87+
if let Ok(rows) = stmt.query_map([date], |row| {
88+
Ok((
89+
row.get::<_, String>(0)?,
90+
row.get::<_, String>(1)?,
91+
row.get::<_, i64>(2).unwrap_or(0),
92+
))
93+
}) {
94+
for (app, title, secs) in rows.flatten() {
95+
let e = by_app.entry(app).or_insert((0, vec![]));
96+
e.0 += secs;
97+
e.1.push(json!({ "title": title, "duration": fmt_dur(secs), "total_secs": secs }));
98+
}
99+
}
100+
101+
let mut apps: Vec<Value> = by_app
102+
.into_iter()
103+
.map(|(name, (total, windows))| {
104+
json!({
105+
"app_name": name,
106+
"total_secs": total,
107+
"duration": fmt_dur(total),
108+
"windows": windows,
109+
})
110+
})
111+
.collect();
112+
apps.sort_by(|a, b| {
113+
b["total_secs"]
114+
.as_i64()
115+
.unwrap_or(0)
116+
.cmp(&a["total_secs"].as_i64().unwrap_or(0))
117+
});
118+
119+
json!({ "date": date, "activity_by_app": apps })
120+
}
121+
122+
// ── Tool: time entries ────────────────────────────────────────────────────────
123+
124+
fn get_time_entries(date: &str) -> Value {
125+
let db = match open_db() {
126+
Ok(c) => c,
127+
Err(e) => return json!({ "error": e }),
128+
};
129+
130+
let mut stmt = match db.prepare(
131+
"SELECT te.id, te.start_minutes, te.end_minutes, te.note, p.name, p.color
132+
FROM time_entries te
133+
JOIN projects p ON p.id = te.project_id
134+
WHERE te.date = ?1
135+
ORDER BY te.start_minutes",
136+
) {
137+
Ok(s) => s,
138+
Err(e) => return json!({ "error": e.to_string() }),
139+
};
140+
141+
let entries: Vec<Value> = match stmt.query_map([date], |row| {
142+
Ok((
143+
row.get::<_, i64>(0)?,
144+
row.get::<_, i64>(1)?,
145+
row.get::<_, i64>(2)?,
146+
row.get::<_, String>(3)?,
147+
row.get::<_, String>(4)?,
148+
row.get::<_, String>(5)?,
149+
))
150+
}) {
151+
Ok(rows) => rows
152+
.flatten()
153+
.map(|(id, start, end, note, project, color)| {
154+
json!({
155+
"id": id,
156+
"project": project,
157+
"color": color,
158+
"start": mins_to_hhmm(start),
159+
"end": mins_to_hhmm(end),
160+
"duration": fmt_dur((end - start) * 60),
161+
"duration_mins": end - start,
162+
"note": note,
163+
})
164+
})
165+
.collect(),
166+
Err(_) => vec![],
167+
};
168+
169+
json!({ "date": date, "entries": entries })
170+
}
171+
172+
// ── Tool: projects ────────────────────────────────────────────────────────────
173+
174+
fn get_projects() -> Value {
175+
let db = match open_db() {
176+
Ok(c) => c,
177+
Err(e) => return json!({ "error": e }),
178+
};
179+
180+
let mut stmt = match db.prepare(
181+
"SELECT id, name, color, parent_id
182+
FROM projects
183+
WHERE archived_at IS NULL
184+
ORDER BY name",
185+
) {
186+
Ok(s) => s,
187+
Err(e) => return json!({ "error": e.to_string() }),
188+
};
189+
190+
let projects: Vec<Value> = match stmt.query_map([], |row| {
191+
let id: i64 = row.get(0)?;
192+
let name: String = row.get(1)?;
193+
let color: String = row.get(2)?;
194+
let parent_id: Option<i64> = row.get(3)?;
195+
Ok(json!({ "id": id, "name": name, "color": color, "parent_id": parent_id }))
196+
}) {
197+
Ok(rows) => rows.flatten().collect(),
198+
Err(_) => vec![],
199+
};
200+
201+
json!({ "projects": projects })
202+
}
203+
204+
// ── Tool: day summary ─────────────────────────────────────────────────────────
205+
206+
fn get_day_summary(date: &str) -> Value {
207+
let activity = get_activity_summary(date);
208+
let entries = get_time_entries(date);
209+
210+
let tracked_secs = activity["activity_by_app"]
211+
.as_array()
212+
.map(|v| v.iter().filter_map(|a| a["total_secs"].as_i64()).sum::<i64>())
213+
.unwrap_or(0);
214+
215+
let logged_mins = entries["entries"]
216+
.as_array()
217+
.map(|v| {
218+
v.iter()
219+
.filter_map(|e| e["duration_mins"].as_i64())
220+
.sum::<i64>()
221+
})
222+
.unwrap_or(0);
223+
224+
json!({
225+
"date": date,
226+
"total_tracked": fmt_dur(tracked_secs),
227+
"total_logged": fmt_dur(logged_mins * 60),
228+
"activity_by_app": activity["activity_by_app"],
229+
"time_entries": entries["entries"],
230+
})
231+
}
232+
233+
// ── MCP protocol ──────────────────────────────────────────────────────────────
234+
235+
fn tools_schema() -> Value {
236+
json!([
237+
{
238+
"name": "get_day_summary",
239+
"description": "Get a combined summary of computer activity (apps used) and logged time entries for a day. Best for 'what did I work on today?' or 'how did I spend Tuesday?'",
240+
"inputSchema": {
241+
"type": "object",
242+
"properties": {
243+
"date": { "type": "string", "description": "YYYY-MM-DD. Defaults to today." }
244+
}
245+
}
246+
},
247+
{
248+
"name": "get_activity_summary",
249+
"description": "Time spent per app and window title on a given day, from continuous background window tracking.",
250+
"inputSchema": {
251+
"type": "object",
252+
"properties": {
253+
"date": { "type": "string", "description": "YYYY-MM-DD. Defaults to today." }
254+
}
255+
}
256+
},
257+
{
258+
"name": "get_time_entries",
259+
"description": "Manually logged time entries (project, note, start/end time) for a given day.",
260+
"inputSchema": {
261+
"type": "object",
262+
"properties": {
263+
"date": { "type": "string", "description": "YYYY-MM-DD. Defaults to today." }
264+
}
265+
}
266+
},
267+
{
268+
"name": "get_projects",
269+
"description": "List all active (non-archived) projects in Timesheeps.",
270+
"inputSchema": { "type": "object", "properties": {} }
271+
}
272+
])
273+
}
274+
275+
fn dispatch_tool(name: &str, args: &Value) -> Value {
276+
let date = args
277+
.get("date")
278+
.and_then(|v| v.as_str())
279+
.map(str::to_string)
280+
.unwrap_or_else(today_local);
281+
282+
match name {
283+
"get_day_summary" => get_day_summary(&date),
284+
"get_activity_summary" => get_activity_summary(&date),
285+
"get_time_entries" => get_time_entries(&date),
286+
"get_projects" => get_projects(),
287+
_ => json!({ "error": format!("Unknown tool: {}", name) }),
288+
}
289+
}
290+
291+
fn handle(msg: &Value) -> Option<Value> {
292+
let method = msg.get("method")?.as_str()?;
293+
let id = msg.get("id").cloned();
294+
295+
// Notifications never get a response
296+
if method.starts_with("notifications/") {
297+
return None;
298+
}
299+
300+
let result: Value = match method {
301+
"initialize" => json!({
302+
"protocolVersion": "2024-11-05",
303+
"capabilities": { "tools": {} },
304+
"serverInfo": { "name": "timesheeps", "version": "1.0.0" }
305+
}),
306+
"ping" => json!({}),
307+
"tools/list" => json!({ "tools": tools_schema() }),
308+
"tools/call" => {
309+
let params = msg.get("params")?;
310+
let name = params.get("name")?.as_str()?;
311+
let args = params.get("arguments").cloned().unwrap_or(json!({}));
312+
let data = dispatch_tool(name, &args);
313+
let text = serde_json::to_string_pretty(&data).unwrap_or_default();
314+
json!({ "content": [{ "type": "text", "text": text }] })
315+
}
316+
_ => {
317+
return Some(json!({
318+
"jsonrpc": "2.0",
319+
"id": id,
320+
"error": { "code": -32601, "message": format!("Method not found: {}", method) }
321+
}));
322+
}
323+
};
324+
325+
Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
326+
}
327+
328+
// ── Entry point ───────────────────────────────────────────────────────────────
329+
330+
fn main() {
331+
let stdin = std::io::stdin();
332+
let stdout = std::io::stdout();
333+
let mut reader = BufReader::new(stdin.lock());
334+
let mut out = stdout.lock();
335+
let mut line = String::new();
336+
337+
loop {
338+
line.clear();
339+
match reader.read_line(&mut line) {
340+
Ok(0) => break,
341+
Ok(_) => {
342+
let trimmed = line.trim();
343+
if trimmed.is_empty() {
344+
continue;
345+
}
346+
if let Ok(msg) = serde_json::from_str::<Value>(trimmed) {
347+
if let Some(resp) = handle(&msg) {
348+
if let Ok(s) = serde_json::to_string(&resp) {
349+
let _ = writeln!(out, "{}", s);
350+
let _ = out.flush();
351+
}
352+
}
353+
}
354+
}
355+
Err(_) => break,
356+
}
357+
}
358+
}

0 commit comments

Comments
 (0)