use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
use crate::config::Config;
use crate::list::list_sessions;
use crate::scanner::ScanFilter;
#[derive(Deserialize)]
struct JsonRpcRequest {
#[allow(dead_code)]
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<Value>,
}
pub fn run_mcp_server() -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let request: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".into(),
id: Value::Null,
result: None,
error: Some(
json!({"code": -32700, "message": format!("parse error: {e}")}),
),
};
write_response(&mut stdout, &resp)?;
continue;
}
};
if request.method.starts_with("notifications/") {
continue;
}
let response = handle_request(&request);
write_response(&mut stdout, &response)?;
}
Ok(())
}
fn write_response(stdout: &mut io::Stdout, resp: &JsonRpcResponse) -> Result<()> {
let json = serde_json::to_string(resp)?;
writeln!(stdout, "{json}")?;
stdout.flush()?;
Ok(())
}
fn handle_request(req: &JsonRpcRequest) -> JsonRpcResponse {
let id = req.id.clone().unwrap_or(Value::Null);
match req.method.as_str() {
"initialize" => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(json!({
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "clync",
"version": env!("CARGO_PKG_VERSION")
}
})),
error: None,
},
"tools/list" => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(json!({
"tools": tool_definitions()
})),
error: None,
},
"tools/call" => {
let tool_name = req
.params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let args = req.params.get("arguments").cloned().unwrap_or(json!({}));
match call_tool(tool_name, &args) {
Ok(result) => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(json!({
"content": [{"type": "text", "text": result}]
})),
error: None,
},
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(json!({
"content": [{"type": "text", "text": format!("error: {e}")}],
"isError": true
})),
error: None,
},
}
}
_ => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(
json!({"code": -32601, "message": format!("unknown method: {}", req.method)}),
),
},
}
}
fn tool_definitions() -> Value {
json!([
{
"name": "list_sessions",
"description": "List Claude Code sessions with optional search. Returns UUID, project, message count, first message preview, size, and modification time for each session.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search sessions by project name, UUID, or first message content"
},
"max_age_days": {
"type": "integer",
"description": "Only show sessions modified within N days"
},
"limit": {
"type": "integer",
"description": "Max number of results (default: 20)"
}
}
}
},
{
"name": "session_detail",
"description": "Get details for a specific session by UUID (or prefix). Returns message count, participants, timestamps, project, and the last N messages.",
"inputSchema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Full or partial UUID of the session"
},
"tail": {
"type": "integer",
"description": "Number of recent messages to include (default: 10)"
}
},
"required": ["uuid"]
}
},
{
"name": "sync_status",
"description": "Show what differs between local sessions and the encrypted sync repo. Lists sessions that are local-only, remote-only, diverged, or in sync.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "sync_push",
"description": "Encrypt and push changed sessions and extras (memories, settings, etc.) to the sync repo.",
"inputSchema": {
"type": "object",
"properties": {
"git": {
"type": "boolean",
"description": "Also git add, commit, and push to remote (default: auto_git config, usually true)"
}
}
}
},
{
"name": "sync_pull",
"description": "Pull and decrypt sessions from sync repo, smart-merging any diverged sessions using UUID-based conversation trees.",
"inputSchema": {
"type": "object",
"properties": {
"git": {
"type": "boolean",
"description": "Also git pull from remote first (default: auto_git config, usually true)"
}
}
}
},
{
"name": "sync_log",
"description": "Show recent sync operations with machine name, timestamps, and what was synced/merged.",
"inputSchema": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Number of recent entries to show (default: 10)"
}
}
}
},
{
"name": "config_show",
"description": "Show current clync configuration: sync repo path, encryption method, and which targets (sessions, memories, settings, etc.) are enabled.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "help",
"description": "Show available clync commands and usage information. Call with a specific topic for detailed help.",
"inputSchema": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "Help topic: 'setup', 'sync', 'list', 'mcp', 'config', or 'all'"
}
}
}
}
])
}
fn call_tool(name: &str, args: &Value) -> Result<String> {
match name {
"list_sessions" => {
let config = Config::load()?;
let query = args.get("query").and_then(|v| v.as_str());
let max_age = args.get("max_age_days").and_then(|v| v.as_u64());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let filter = ScanFilter {
max_age_days: max_age,
max_file_size: None,
};
let sessions = list_sessions(&config.claude_projects_dir(), query, &filter)?;
let limited: Vec<_> = sessions.into_iter().take(limit).collect();
Ok(serde_json::to_string_pretty(&limited)?)
}
"session_detail" => {
let config = Config::load()?;
let uuid_prefix = args
.get("uuid")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("uuid is required"))?;
let tail = args.get("tail").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let filter = ScanFilter::default();
let sessions =
crate::scanner::scan_sessions(&config.claude_projects_dir(), &filter)?;
let session = sessions
.iter()
.find(|s| s.uuid.starts_with(uuid_prefix))
.ok_or_else(|| anyhow::anyhow!("no session matching '{uuid_prefix}'"))?;
let entries = crate::parser::parse_jsonl_file(&session.jsonl_path)?;
let user_msgs = entries
.iter()
.filter(|e| e.entry_type.as_deref() == Some("user"))
.count();
let assistant_msgs = entries
.iter()
.filter(|e| e.entry_type.as_deref() == Some("assistant"))
.count();
let first_ts = entries.first().map(|e| e.timestamp_millis()).unwrap_or(0);
let last_ts = entries.last().map(|e| e.timestamp_millis()).unwrap_or(0);
let recent: Vec<Value> = entries
.iter()
.filter(|e| {
matches!(
e.entry_type.as_deref(),
Some("user") | Some("assistant")
)
})
.rev()
.take(tail)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|e| {
let role = e.entry_type.as_deref().unwrap_or("unknown");
let content = e
.extra
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| {
c.as_str().map(|s| s.to_string()).or_else(|| {
c.as_array().map(|arr| {
arr.iter()
.filter_map(|item| {
item.get("text").and_then(|t| t.as_str())
})
.collect::<Vec<_>>()
.join("\n")
})
})
})
.unwrap_or_default();
let truncated = truncate_str(&content, 500);
json!({"role": role, "content": truncated})
})
.collect();
let detail = json!({
"uuid": session.uuid,
"project": session.entry.project_path,
"size_bytes": session.entry.size,
"user_messages": user_msgs,
"assistant_messages": assistant_msgs,
"total_entries": entries.len(),
"first_timestamp": first_ts,
"last_timestamp": last_ts,
"recent_messages": recent
});
Ok(serde_json::to_string_pretty(&detail)?)
}
"sync_status" => {
let config = Config::load()?;
let cipher = crate::crypto::Cipher::from_config(&config.encryption)?;
let storage = crate::storage::GitStorage::new(config.sync.repo.clone());
let filter = ScanFilter::default();
let result = crate::sync::status(&config, &cipher, &filter, &storage)?;
let mut output = String::new();
if result.local_only.is_empty()
&& result.remote_only.is_empty()
&& result.diverged.is_empty()
{
output.push_str(&format!("all {} sessions in sync", result.in_sync));
} else {
for s in &result.local_only {
output.push_str(&format!(
"+ {} [{}] (local only)\n",
short_uuid(&s.uuid),
s.project
));
}
for s in &result.remote_only {
output.push_str(&format!(
"- {} [{}] (remote only)\n",
short_uuid(&s.uuid),
s.project
));
}
for s in &result.diverged {
output.push_str(&format!(
"~ {} [{}] (diverged)\n",
short_uuid(&s.uuid),
s.project
));
}
if result.in_sync > 0 {
output.push_str(&format!("in sync: {}", result.in_sync));
}
}
Ok(output)
}
"sync_push" => {
let config = Config::load()?;
let git = args.get("git").and_then(|v| v.as_bool()).unwrap_or(config.sync.auto_git);
let r = crate::do_push(git)?;
Ok(format!(
"pushed {} sessions ({} unchanged), {} extras",
r.sessions, r.skipped, r.extras
))
}
"sync_pull" => {
let config = Config::load()?;
let git = args.get("git").and_then(|v| v.as_bool()).unwrap_or(config.sync.auto_git);
let r = crate::do_pull(git)?;
Ok(format!(
"pulled {} new, {} merged, {} unchanged, {} extras",
r.pulled, r.merged, r.skipped, r.extras
))
}
"sync_log" => {
let config = Config::load()?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let entries = crate::synclog::read_recent(&config.sync.repo, limit)?;
Ok(serde_json::to_string_pretty(&entries)?)
}
"config_show" => {
let config = Config::load()?;
let enc_method = match &config.encryption {
crate::config::EncryptionConfig::KeyFile { path } => {
format!("key_file ({})", path.display())
}
crate::config::EncryptionConfig::Passphrase { env_var } => {
format!("passphrase (${env_var})")
}
crate::config::EncryptionConfig::OnePassword { reference } => {
format!("1password ({reference})")
}
crate::config::EncryptionConfig::Bitwarden { item_id, .. } => {
format!("bitwarden ({item_id})")
}
crate::config::EncryptionConfig::Pass { entry } => {
format!("pass ({entry})")
}
crate::config::EncryptionConfig::None => "none (plain text)".into(),
};
let t = &config.targets;
Ok(format!(
"sync repo: {}\nclaude dir: {}\nencryption: {}\ncompanion dirs: {}\n\ntargets:\n sessions: {}\n memories: {}\n settings: {}\n commands: {}\n skills: {}\n global CLAUDE.md: {}",
config.sync.repo.display(),
config.sync.claude_dir.display(),
enc_method,
config.sync.include_companion_dirs,
t.sessions, t.memories, t.settings, t.commands, t.skills, t.global_claude_md
))
}
"help" => {
let topic = args
.get("topic")
.and_then(|v| v.as_str())
.unwrap_or("all");
Ok(help_text(topic))
}
_ => anyhow::bail!("unknown tool: {name}"),
}
}
fn help_text(topic: &str) -> String {
match topic {
"setup" => "\
# clync setup
1. Install: cargo install clync
2. Initialize: clync init --repo ~/clync-repo
- Add --onepassword 'op://vault/clync/age-key' for 1Password key storage
3. Add remote: cd ~/clync-repo && git remote add origin <url>
4. First sync: clync push --git
For 1Password: store the printed secret key at the op:// path, then verify with `op read`."
.into(),
"sync" => "\
# clync sync commands
clync push [--git] [--max-age DAYS] [--max-size BYTES]
Encrypt changed sessions and extras, commit to sync repo.
--git also runs git add/commit/push.
clync pull [--git] [--max-age DAYS] [--max-size BYTES]
Decrypt and smart-merge remote sessions into local.
--git also runs git pull first.
clync sync [--git]
Bidirectional: pull then push.
clync status [--max-age DAYS]
Show what's different between local and remote.
Smart merge: when the same session was edited on two machines, clync merges
using UUID-based conversation trees. Same-UUID entries with different content
are resolved by keeping the newer timestamp."
.into(),
"list" => "\
# clync list
clync list [QUERY] [--max-age DAYS] [-n LIMIT] [--json]
Search sessions by project name, UUID, or first message content.
Results are sorted by most recently modified.
Examples:
clync list # show 20 most recent sessions
clync list security # search for 'security'
clync list --max-age 7 # last week only
clync list --json -n 5 # JSON output, 5 results"
.into(),
"mcp" => "\
# clync MCP server
Run as a stdio MCP server for Claude Code integration:
clync mcp
Add to Claude Code's MCP config:
{
\"mcpServers\": {
\"clync\": {
\"command\": \"clync\",
\"args\": [\"mcp\"]
}
}
}
Available tools:
list_sessions - search sessions by project/UUID/content
session_detail - get details and recent messages for a session
sync_status - show local vs remote diff
sync_push - encrypt and push to sync repo
sync_pull - pull and decrypt from sync repo
config_show - show current configuration
help - this help (with optional topic)"
.into(),
"config" => "\
# clync configuration
Config location: ~/Library/Application Support/clync/config.toml (macOS)
~/.config/clync/config.toml (Linux)
[sync]
repo = '~/clync-repo' # path to the git sync repo
claude_dir = '~/.claude' # claude code data directory
include_companion_dirs = false # sync subagent/tool-result dirs
[encryption]
method = 'key_file' # or 'onepassword'
path = '~/.config/clync/key.txt' # age secret key file
# reference = 'op://vault/item' # for 1Password method
[targets]
sessions = true # conversation JSONL files
memories = true # project memory files
settings = false # settings.json, settings.local.json
commands = false # custom slash commands
skills = false # custom skills
global_claude_md = false # ~/.claude/CLAUDE.md"
.into(),
_ => "\
# clync - encrypted sync for Claude Code
Commands:
clync init Initialize config, generate age key, set up sync repo
clync push Encrypt and push to sync repo
clync pull Decrypt and smart-merge from sync repo
clync sync Bidirectional sync (pull + push)
clync status Show what differs between local and remote
clync list Search and browse local sessions
clync mcp Run as MCP server (stdio JSON-RPC)
Help topics: setup, sync, list, mcp, config
clync help # or via MCP: help tool with topic param
Encryption: age (https://age-encryption.org)
Key storage: local file or 1Password CLI (op://)"
.into(),
}
}
fn short_uuid(uuid: &str) -> &str {
let mut end = uuid.len().min(8);
while end > 0 && !uuid.is_char_boundary(end) {
end -= 1;
}
&uuid[..end]
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max.saturating_sub(3);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}