use crate::client;
use crate::config::Paths;
use crate::receive::Payload;
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
const TOOL_DESCRIPTION: &str = "Send a finished document to snyvi, the user's document viewer. \
Call this whenever you finish writing a plan, report, review, summary, design note, or any document the user \
will want to read, and whenever the user asks to see a file. Prefer `path` for files you wrote to disk; use \
`content` for text that is not a file (a review, a summary, a diff). Markdown and every kind of source file \
are supported. The document arrives at once and waits in the viewer to be read. The result says how to tell \
the user where it is: when snyvi has its own window open it is already there and a link would only send them \
to a browser beside it, so say it is waiting in snyvi; otherwise give them the url the result carries.";
pub fn run(paths: Paths) -> anyhow::Result<()> {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string());
let session = session_key();
let mut sender: Option<String> = None;
let stdin = io::stdin();
let mut out = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
write_msg(
&mut out,
&json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("parse error: {e}") } }),
)?;
continue;
}
};
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else { continue };
let reply = match method {
"initialize" => {
sender = params
.pointer("/clientInfo/name")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(name) = &sender {
client::hold_presence(name.clone());
}
json!({ "jsonrpc": "2.0", "id": id, "result": {
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"),
"capabilities": { "tools": {} },
"serverInfo": { "name": "snyvi", "version": env!("CARGO_PKG_VERSION") },
"instructions": "snyvi is the user's document viewer. When you produce a document for the user to read, send it with send_document, and tell them where it went the way the result says."
}})
}
"ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }),
"tools/list" => {
json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [ tool_spec() ] } })
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
if name != "send_document" {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": format!("unknown tool {name}") } })
} else {
match call_send(&paths, args, cwd.as_deref(), &session, sender.as_deref()) {
Ok(sent) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": sent.say() }],
"structuredContent": { "url": sent.url, "app_url": sent.app_url, "window": sent.window, "title": sent.title },
"isError": false
}}),
Err(e) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": format!("snyvi could not receive the document: {e}") }],
"isError": true
}}),
}
}
}
_ => {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } })
}
};
write_msg(&mut out, &reply)?;
}
Ok(())
}
fn tool_spec() -> Value {
json!({
"name": "send_document",
"title": "Send document to snyvi",
"description": TOOL_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute path of a file to send. Either path or content is required." },
"content": { "type": "string", "description": "Inline document text, for documents that are not files." },
"title": { "type": "string", "description": "Title shown in the viewer. Defaults to the first heading or the file name." },
"workflow": { "type": "string", "description": "Optional name grouping related documents, e.g. 'auth refactor'. Defaults to this session." },
"lang": { "type": "string", "description": "Format hint when it cannot be inferred from the path: md, diff, rs, py, ts, ..." }
},
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }
})
}
struct Sent {
url: String,
app_url: Option<String>,
title: String,
window: bool,
}
impl Sent {
fn say(&self) -> String {
if self.window {
format!(
"Waiting in snyvi: \"{}\". It is in the snyvi window, at the top of the queue; \
tell the user it is there rather than giving them a link.",
self.title
)
} else if let Some(app) = &self.app_url {
format!(
"Waiting in snyvi: \"{}\". Give the user this link, which opens it in the snyvi app: {} \
(the same document in a browser: {})",
self.title, app, self.url
)
} else {
format!(
"Waiting in snyvi: \"{}\". Give the user this link to read it: {}",
self.title, self.url
)
}
}
}
fn call_send(
paths: &Paths,
args: Value,
cwd: Option<&str>,
session: &str,
sender: Option<&str>,
) -> anyhow::Result<Sent> {
let s = |k: &str| {
args.get(k)
.and_then(Value::as_str)
.map(str::to_string)
.filter(|v| !v.trim().is_empty())
};
let session = cwd
.and_then(|c| crate::session::lookup(paths, c))
.map(|id| crate::session::workflow_key(&id))
.unwrap_or_else(|| session.to_string());
let payload = Payload {
path: s("path"),
content: s("content"),
title: s("title"),
workflow: s("workflow"),
lang: s("lang"),
cwd: cwd.map(str::to_string),
session: Some(session.to_string()),
origin: Some("mcp".into()),
sender: sender.map(str::to_string),
};
let resp = client::send(paths, &payload)?;
Ok(Sent {
url: resp
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
app_url: resp
.get("app_url")
.and_then(Value::as_str)
.map(str::to_string),
title: resp
.get("doc")
.and_then(|d| d.get("title"))
.and_then(Value::as_str)
.unwrap_or("document")
.to_string(),
window: resp.get("window").and_then(Value::as_bool).unwrap_or(false),
})
}
fn write_msg(out: &mut impl Write, v: &Value) -> io::Result<()> {
serde_json::to_writer(&mut *out, v)?;
out.write_all(b"\n")?;
out.flush()
}
fn session_key() -> String {
use time::{macros::format_description, OffsetDateTime};
let t = OffsetDateTime::now_utc()
.format(format_description!("[year][month][day]-[hour][minute]"))
.unwrap_or_default();
let tail =
blake3::hash(format!("{}-{}", std::process::id(), t).as_bytes()).to_hex()[..4].to_string();
format!("session {t} {tail}")
}