mod setup;
use rmcp::ServerHandler;
use rmcp::{
handler::server::{
tool::ToolRouter,
wrapper::Parameters,
},
model::*,
tool, tool_handler, tool_router, ServiceExt,
};
use serde::Deserialize;
const API: &str = "https://ookcite-api.turtletech.us";
fn url(path: &str) -> String {
format!("{API}{path}")
}
#[derive(Clone)]
struct Server {
tool_router: ToolRouter<Self>,
http: reqwest::Client,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct DoiArgs {
doi: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct IsbnArgs {
isbn: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ReverseArgs {
text: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct FormatArgs {
doi: String,
#[serde(default = "default_style")]
style: String,
}
fn default_style() -> String {
"apa".into()
}
#[derive(Deserialize, schemars::JsonSchema)]
struct VerifyArgs {
dois: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct BatchArgs {
citations: Vec<String>,
#[serde(default = "default_style")]
style: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct StyleSearchArgs {
query: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct GroupCiteArgs {
dois: Vec<String>,
#[serde(default = "default_style")]
style: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ListCollectionsArgs {}
#[derive(Deserialize, schemars::JsonSchema)]
struct AddToCollectionArgs {
collection: String,
query: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ExportCollectionArgs {
collection: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct SearchCollectionArgs {
collection: String,
query: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct HealthCheckArgs {}
#[derive(Deserialize, schemars::JsonSchema)]
struct ImportBibliographyArgs {
collection: String,
content: String,
#[serde(default = "default_bibtex")]
format: String,
}
fn default_bibtex() -> String {
"bibtex".into()
}
#[derive(Deserialize, schemars::JsonSchema)]
struct CheckDuplicatesArgs {
collection: String,
query: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct BatchAddArgs {
collection: String,
queries: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct DeleteCollectionArgs {
collection: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct UpdateCollectionArgs {
collection: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
default_style: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct RemoveFromCollectionArgs {
collection: String,
entry_id: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct UpdateTagsArgs {
collection: String,
tags: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ReorderCollectionArgs {
collection: String,
entry_ids: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ShareCollectionArgs {
collection: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct UnshareCollectionArgs {
collection: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct MergeCollectionsArgs {
collections: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct BatchMoveArgs {
source: String,
target: String,
entry_ids: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ViewSharedArgs {
share_token: String,
}
#[tool_router]
impl Server {
fn new() -> Self {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(api_key) = std::env::var("OOKCITE_API_KEY") {
if let Ok(mut auth_val) =
format!("Bearer {api_key}").parse::<reqwest::header::HeaderValue>()
{
auth_val.set_sensitive(true);
headers.insert(reqwest::header::AUTHORIZATION, auth_val);
}
} else {
eprintln!(
"ookcite-mcp: OOKCITE_API_KEY not set; requests will be anonymous/IP-rate-limited"
);
}
Self {
tool_router: Self::tool_router(),
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.default_headers(headers)
.build()
.unwrap(),
}
}
#[tool(
name = "search_styles",
description = "Search for available CSL citation styles by name. Returns a list of matching style IDs to use in formatting tools."
)]
async fn search_styles(
&self,
Parameters(args): Parameters<StyleSearchArgs>,
) -> String {
let req_url = url(&format!(
"/api/v1/styles/search?q={}",
urlencoding::encode(&args.query)
));
let r = self.http.get(&req_url).send().await;
match r {
Ok(resp) if resp.status().is_success() => {
let styles: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
let mut out = Vec::new();
for s in styles.iter().take(15) {
let id = s["id"].as_str().unwrap_or("?");
let title = s["title"].as_str().unwrap_or("?");
out.push(format!("ID: {id} | Title: {title}"));
}
if out.is_empty() { "No styles found".into() } else { out.join("\n") }
}
_ => "Style search failed".into(),
}
}
#[tool(
name = "validate_doi",
description = "Check if a DOI exists in CrossRef and return its metadata. Use this to verify citations. Returns title, authors, year, journal, volume, and issue."
)]
async fn validate_doi(&self, Parameters(args): Parameters<DoiArgs>) -> String {
let r = self
.http
.post(url("/api/v1/lookup/doi"))
.json(&serde_json::json!({"doi": args.doi}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let meta: serde_json::Value = resp.json().await.unwrap_or_default();
let title = meta["title"].as_str().unwrap_or("?");
let authors = meta["authors"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x["family"].as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let year = meta["date"]["year"]
.as_i64()
.map(|y| y.to_string())
.unwrap_or_default();
let journal = meta["journal"].as_str().unwrap_or("N/A");
let volume = meta["volume"].as_str().unwrap_or("N/A");
let issue = meta["issue"].as_str().unwrap_or("N/A");
let doi = meta["doi"].as_str().unwrap_or(&args.doi);
format!("VALID\nDOI: {doi}\nTitle: {title}\nAuthors: {authors}\nYear: {year}\nJournal: {journal}\nVolume: {volume}\nIssue: {issue}")
}
_ => format!(
"INVALID: DOI {} not found in CrossRef. This citation may represent a hallucination.",
args.doi
),
}
}
#[tool(
name = "lookup_isbn",
description = "Look up a book by ISBN. Returns title, authors, publisher, year, and pages."
)]
async fn lookup_isbn(&self, Parameters(args): Parameters<IsbnArgs>) -> String {
let r = self
.http
.post(url("/api/v1/lookup/isbn"))
.json(&serde_json::json!({"isbn": args.isbn}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let meta: serde_json::Value = resp.json().await.unwrap_or_default();
let title = meta["title"].as_str().unwrap_or("?");
let authors = meta["authors"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x["family"].as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let year = meta["date"]["year"]
.as_i64()
.map(|y| y.to_string())
.unwrap_or_default();
let publisher = meta["publisher"].as_str().unwrap_or("N/A");
let pages = meta["pages"].as_str().unwrap_or("N/A");
format!(
"VALID\nISBN: {}\nTitle: {title}\nAuthors: {authors}\nYear: {year}\nPublisher: {publisher}\nPages: {pages}",
args.isbn
)
}
_ => format!("ISBN {} not found", args.isbn),
}
}
#[tool(
name = "reverse_lookup",
description = "Parse a messy citation string and find the matching paper in CrossRef. Returns ranked candidates."
)]
async fn reverse_lookup(&self, Parameters(args): Parameters<ReverseArgs>) -> String {
let r = self
.http
.post(url("/api/v1/reverse"))
.json(&serde_json::json!({"text": args.text}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let candidates: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
let mut out = Vec::new();
for (i, c) in candidates.iter().enumerate() {
let title = c["metadata"]["title"].as_str().unwrap_or("?");
let doi = c["metadata"]["doi"].as_str().unwrap_or("?");
let journal = c["metadata"]["journal"].as_str().unwrap_or("N/A");
let score = c["score"].as_f64().unwrap_or(0.0);
out.push(format!(
"{}. [score:{:.0}] {title} | {journal} (doi:{doi})",
i + 1,
score
));
}
if out.is_empty() { "No matches found".into() } else { out.join("\n") }
}
_ => "Reverse lookup failed".into(),
}
}
#[tool(
name = "format_citation",
description = "Format a citation by DOI in a specific CSL style. Returns both the in-text marker and the full bibliography entry."
)]
async fn format_citation(&self, Parameters(args): Parameters<FormatArgs>) -> String {
let lookup = self
.http
.post(url("/api/v1/lookup/doi"))
.json(&serde_json::json!({"doi": args.doi}))
.send()
.await;
let meta: serde_json::Value = match lookup {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => return format!("DOI {} not found", args.doi),
};
let fmt = self
.http
.post(url("/api/v1/format"))
.json(&serde_json::json!({"entries": [meta], "style": args.style, "locale": "en-US"}))
.send()
.await;
match fmt {
Ok(r) if r.status().is_success() => {
let result: serde_json::Value = r.json().await.unwrap_or_default();
let plain = result["plain"].as_str().unwrap_or("").trim();
let intext = result["citations"]
.as_array()
.and_then(|a| a.first())
.and_then(|c| c["plain"].as_str())
.unwrap_or("");
format!("In-text: {intext}\nReference: {plain}")
}
_ => "Format failed".into(),
}
}
#[tool(
name = "group_cite",
description = "Generate a grouped in-text citation marker (e.g., '[1-3]') for multiple DOIs."
)]
async fn group_cite(&self, Parameters(args): Parameters<GroupCiteArgs>) -> String {
let mut entries = Vec::new();
for doi in &args.dois {
let r = self
.http
.post(url("/api/v1/lookup/doi"))
.json(&serde_json::json!({"doi": doi}))
.send()
.await;
if let Ok(resp) = r {
if resp.status().is_success() {
if let Ok(meta) = resp.json::<serde_json::Value>().await {
entries.push(meta);
}
}
}
}
if entries.is_empty() {
return "Failed to resolve any DOIs.".into();
}
let indices: Vec<usize> = (0..entries.len()).collect();
let r = self
.http
.post(url("/api/v1/format/group-cite"))
.json(&serde_json::json!({
"entries": entries,
"indices": indices,
"style": args.style
}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let result: serde_json::Value = resp.json().await.unwrap_or_default();
let plain = result["plain"].as_str().unwrap_or("");
format!("Grouped Citation: {plain}")
}
_ => "Group citation failed".into(),
}
}
#[tool(
name = "verify_references",
description = "Batch verify that a list of DOIs exist. Returns VALID or INVALID for each."
)]
async fn verify_references(
&self,
Parameters(args): Parameters<VerifyArgs>,
) -> String {
let mut results = Vec::new();
for doi in &args.dois {
let r = self
.http
.post(url("/api/v1/lookup/doi"))
.json(&serde_json::json!({"doi": doi}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let meta: serde_json::Value = resp.json().await.unwrap_or_default();
let title = meta["title"].as_str().unwrap_or("?");
results.push(format!("VALID {doi} : {title}"));
}
_ => results.push(format!("INVALID {doi} : NOT FOUND")),
}
}
results.join("\n")
}
#[tool(
name = "batch_format",
description = "Resolve and format multiple messy citations at once. Pass citation strings in any format."
)]
async fn batch_format(&self, Parameters(args): Parameters<BatchArgs>) -> String {
let mut entries = Vec::new();
let mut errors = Vec::new();
for (i, text) in args.citations.iter().enumerate() {
let r = self
.http
.post(url("/api/v1/reverse"))
.json(&serde_json::json!({"text": text}))
.send()
.await;
match r {
Ok(resp) if resp.status().is_success() => {
let candidates: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
if let Some(meta) = candidates.first().and_then(|c| c.get("metadata")) {
entries.push(meta.clone());
} else {
errors.push(format!(
"[{}] Not found: {}",
i + 1,
&text[..text.len().min(60)]
));
}
}
_ => errors.push(format!(
"[{}] Failed: {}",
i + 1,
&text[..text.len().min(60)]
)),
}
}
if entries.is_empty() {
return format!("No citations resolved.\n{}", errors.join("\n"));
}
let fmt = self
.http
.post(url("/api/v1/format"))
.json(&serde_json::json!({"entries": entries, "style": args.style, "locale": "en-US"}))
.send()
.await;
match fmt {
Ok(r) if r.status().is_success() => {
let result: serde_json::Value = r.json().await.unwrap_or_default();
let mut out = Vec::new();
if let Some(fe) = result["entries"].as_array() {
for entry in fe {
let intext = entry["intext_plain"].as_str().unwrap_or("");
let bib = entry["bib_plain"].as_str().unwrap_or("").trim();
out.push(format!("{intext} {bib}"));
}
}
if !errors.is_empty() {
out.push("\n*** Unresolved ***".into());
out.extend(errors);
}
out.join("\n")
}
_ => "Batch format failed".into(),
}
}
#[tool(
name = "list_collections",
description = "List all citation collections for the authenticated user. Requires OOKCITE_API_KEY."
)]
async fn list_collections(
&self,
#[allow(unused)] Parameters(_args): Parameters<ListCollectionsArgs>,
) -> String {
let r = self.http.get(url("/api/v1/collections")).send().await;
match r {
Ok(r) if r.status().is_success() => {
let cols: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
if cols.is_empty() {
return "No collections found. Create one with add_to_collection.".into();
}
cols.iter()
.map(|c| {
format!(
"- {} ({} entries){}",
c["name"].as_str().unwrap_or("?"),
c["entry_count"].as_u64().unwrap_or(0),
c["tags"].as_array().map_or(String::new(), |t| {
if t.is_empty() { String::new() }
else { format!(" [{}]", t.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")) }
})
)
})
.collect::<Vec<_>>()
.join("\n")
}
Ok(r) if r.status().as_u16() == 401 => "Authentication required. Set OOKCITE_API_KEY.".into(),
Ok(r) if r.status().as_u16() == 503 => "Collections not available (S3 not configured).".into(),
_ => "Failed to list collections.".into(),
}
}
#[tool(
name = "add_to_collection",
description = "Add a citation to a collection. Searches by DOI, ISBN, or free-text (e.g. 'Goswami JCTC 2026'). Creates the collection if it doesn't exist."
)]
async fn add_to_collection(
&self,
Parameters(args): Parameters<AddToCollectionArgs>,
) -> String {
let col_id = match self.resolve_or_create_collection(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let Some(metadata) = self.resolve_query_to_metadata(&args.query).await else {
return format!("Could not resolve: {}", args.query);
};
let r = self.http.post(url(&format!("/api/v1/collections/{col_id}/entries")))
.json(&serde_json::json!({"metadata": metadata}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let title = metadata["title"].as_str().unwrap_or("(untitled)");
format!("Added to {}: {title}", args.collection)
}
_ => "Failed to add entry to collection.".into(),
}
}
#[tool(
name = "export_collection",
description = "Export a collection as BibTeX. Returns the full .bib file content with Better BibTeX keys."
)]
async fn export_collection(
&self,
Parameters(args): Parameters<ExportCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http.get(url(&format!("/api/v1/collections/{col_id}/export.bib"))).send().await;
match r {
Ok(r) if r.status().is_success() => r.text().await.unwrap_or_else(|_| "Export failed.".into()),
_ => "Failed to export collection.".into(),
}
}
#[tool(
name = "search_collection",
description = "Search within a collection by author name, title keywords, or journal. Returns matching entries."
)]
async fn search_collection(
&self,
Parameters(args): Parameters<SearchCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http.get(url(&format!("/api/v1/collections/{col_id}"))).send().await;
let collection: serde_json::Value = match r {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => return "Failed to load collection.".into(),
};
let query_lower = args.query.to_lowercase();
let entries = collection["entries"].as_array().cloned().unwrap_or_default();
let matches: Vec<String> = entries.iter().filter(|e| {
let meta = &e["metadata"];
let title = meta["title"].as_str().unwrap_or("").to_lowercase();
let authors = meta["authors"].as_array().map(|a| {
a.iter().filter_map(|p| p["family"].as_str()).collect::<Vec<_>>().join(" ").to_lowercase()
}).unwrap_or_default();
let journal = meta["journal"].as_str().unwrap_or("").to_lowercase();
title.contains(&query_lower) || authors.contains(&query_lower) || journal.contains(&query_lower)
}).map(|e| {
let meta = &e["metadata"];
let title = meta["title"].as_str().unwrap_or("?");
let authors = meta["authors"].as_array().map(|a| {
a.iter().filter_map(|p| p["family"].as_str()).collect::<Vec<_>>().join(", ")
}).unwrap_or_default();
let year = meta["date"]["year"].as_i64().map(|y| format!(" ({y})")).unwrap_or_default();
format!("- {authors}{year}: {title}")
}).collect();
if matches.is_empty() {
format!("No entries matching '{}' in collection '{}'.", args.query, args.collection)
} else {
format!("{} matches in '{}':\n{}", matches.len(), args.collection, matches.join("\n"))
}
}
async fn resolve_collection_id(&self, name: &str) -> Result<String, String> {
let cols: Vec<serde_json::Value> = match self.http.get(url("/api/v1/collections")).send().await {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
Ok(r) if r.status().as_u16() == 401 => return Err("Authentication required. Set OOKCITE_API_KEY.".into()),
_ => return Err("Failed to list collections.".into()),
};
cols.iter()
.find(|c| c["name"].as_str() == Some(name))
.and_then(|c| c["id"].as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("Collection '{name}' not found."))
}
async fn resolve_or_create_collection(&self, name: &str) -> Result<String, String> {
match self.resolve_collection_id(name).await {
Ok(id) => Ok(id),
Err(_) => {
let r = self.http.post(url("/api/v1/collections"))
.json(&serde_json::json!({"name": name}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let c: serde_json::Value = r.json().await.unwrap_or_default();
c["id"].as_str().map(|s| s.to_string())
.ok_or_else(|| "Failed to create collection.".into())
}
_ => Err("Failed to create collection.".into()),
}
}
}
}
async fn resolve_query_to_metadata(&self, query: &str) -> Option<serde_json::Value> {
let q = query.trim();
if q.starts_with("10.") {
let r = self.http.post(url("/api/v1/lookup/doi"))
.json(&serde_json::json!({"doi": q})).send().await;
match r {
Ok(r) if r.status().is_success() => Some(r.json::<serde_json::Value>().await.unwrap_or_default()),
_ => None,
}
} else {
let r = self.http.post(url("/api/v1/reverse"))
.json(&serde_json::json!({"text": q})).send().await;
match r {
Ok(r) if r.status().is_success() => {
let results: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
results.first().and_then(|r| r.get("metadata")).cloned()
}
_ => None,
}
}
}
#[tool(
name = "health_check",
description = "Check if the OokCite API is reachable and healthy. Returns service status and cache statistics."
)]
async fn health_check(
&self,
#[allow(unused)] Parameters(_args): Parameters<HealthCheckArgs>,
) -> String {
let r = self.http.get(url("/api/health")).send().await;
match r {
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().await.unwrap_or_default();
let status = data["status"].as_str().unwrap_or("unknown");
let version = data["version"].as_str().unwrap_or("unknown");
let mut out = format!("Status: {status}\nVersion: {version}");
if let Some(cache) = data.get("cache") {
let hits = cache["hits"].as_u64().unwrap_or(0);
let misses = cache["misses"].as_u64().unwrap_or(0);
out.push_str(&format!("\nCache: {hits} hits, {misses} misses"));
}
out
}
Ok(resp) => format!("API unhealthy: HTTP {}", resp.status()),
Err(e) => format!("API unreachable: {e}"),
}
}
#[tool(
name = "import_bibliography",
description = "Import a BibTeX (.bib) or RIS file into a collection. Pass the file content as a string. Creates the collection if it doesn't exist."
)]
async fn import_bibliography(
&self,
Parameters(args): Parameters<ImportBibliographyArgs>,
) -> String {
let col_id = match self.resolve_or_create_collection(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let filename = if args.format == "ris" { "import.ris" } else { "import.bib" };
let part = match reqwest::multipart::Part::text(args.content)
.file_name(filename.to_string())
.mime_str("text/plain")
{
Ok(p) => p,
Err(_) => return "Failed to construct upload.".into(),
};
let form = reqwest::multipart::Form::new().part("file", part);
let r = self.http
.post(url(&format!("/api/v1/collections/{col_id}/import")))
.multipart(form)
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let data: serde_json::Value = r.json().await.unwrap_or_default();
let added = data["added"].as_u64().unwrap_or(0);
let dupes = data["duplicates_skipped"].as_u64().unwrap_or(0);
format!("Imported into '{}': {added} added, {dupes} duplicates skipped", args.collection)
}
Ok(r) if r.status().as_u16() == 401 => "Authentication required. Set OOKCITE_API_KEY.".into(),
_ => "Import failed.".into(),
}
}
#[tool(
name = "check_duplicates",
description = "Check if a citation already exists in a collection. Resolves the query first, then checks for duplicates."
)]
async fn check_duplicates(
&self,
Parameters(args): Parameters<CheckDuplicatesArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let Some(metadata) = self.resolve_query_to_metadata(&args.query).await else {
return format!("Could not resolve: {}", args.query);
};
let r = self.http
.post(url(&format!("/api/v1/collections/{col_id}/check-duplicates")))
.json(&serde_json::json!({"metadata": metadata}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let matches: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
if matches.is_empty() {
"No duplicates found.".into()
} else {
let mut out = vec![format!("{} potential duplicate(s):", matches.len())];
for m in &matches {
let match_type = m["match_type"].as_str().unwrap_or("?");
let similarity = m["similarity"].as_f64().unwrap_or(0.0);
let entry_id = m["entry_id"].as_str().unwrap_or("?");
out.push(format!("- {match_type} ({similarity:.0}%) entry:{entry_id}"));
}
out.join("\n")
}
}
_ => "Duplicate check failed.".into(),
}
}
#[tool(
name = "batch_add_to_collection",
description = "Add multiple citations to a collection at once. Each query can be a DOI or free-text search."
)]
async fn batch_add_to_collection(
&self,
Parameters(args): Parameters<BatchAddArgs>,
) -> String {
let col_id = match self.resolve_or_create_collection(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let mut entries = Vec::new();
let mut errors = Vec::new();
for (i, query) in args.queries.iter().enumerate() {
match self.resolve_query_to_metadata(query).await {
Some(meta) => entries.push(meta),
None => errors.push(format!("[{}] Could not resolve: {}", i + 1, &query[..query.len().min(60)])),
}
}
if entries.is_empty() {
return format!("No citations resolved.\n{}", errors.join("\n"));
}
let r = self.http
.post(url(&format!("/api/v1/collections/{col_id}/entries/batch")))
.json(&serde_json::json!({"entries": entries}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let data: serde_json::Value = r.json().await.unwrap_or_default();
let added = data["added"].as_u64().unwrap_or(0);
let dupes = data["duplicates_skipped"].as_u64().unwrap_or(0);
let mut out = format!("Added {added} to '{}', {dupes} duplicates skipped", args.collection);
if !errors.is_empty() {
out.push_str(&format!("\n\nUnresolved:\n{}", errors.join("\n")));
}
out
}
_ => "Batch add failed.".into(),
}
}
#[tool(
name = "delete_collection",
description = "Delete a citation collection. This is irreversible."
)]
async fn delete_collection(
&self,
Parameters(args): Parameters<DeleteCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http.delete(url(&format!("/api/v1/collections/{col_id}"))).send().await;
match r {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
format!("Deleted collection '{}'.", args.collection)
}
_ => "Failed to delete collection.".into(),
}
}
#[tool(
name = "update_collection",
description = "Update a collection's name, description, or default citation style."
)]
async fn update_collection(
&self,
Parameters(args): Parameters<UpdateCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let mut body = serde_json::Map::new();
if let Some(name) = &args.name {
body.insert("name".into(), serde_json::json!(name));
}
if let Some(desc) = &args.description {
body.insert("description".into(), serde_json::json!(desc));
}
if let Some(style) = &args.default_style {
body.insert("default_style".into(), serde_json::json!(style));
}
if body.is_empty() {
return "Nothing to update. Provide name, description, or default_style.".into();
}
let r = self.http
.patch(url(&format!("/api/v1/collections/{col_id}")))
.json(&serde_json::Value::Object(body))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
format!("Updated collection '{}'.", args.collection)
}
_ => "Failed to update collection.".into(),
}
}
#[tool(
name = "remove_from_collection",
description = "Remove a specific entry from a collection by its entry ID."
)]
async fn remove_from_collection(
&self,
Parameters(args): Parameters<RemoveFromCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.delete(url(&format!("/api/v1/collections/{col_id}/entries/{}", args.entry_id)))
.send().await;
match r {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
format!("Removed entry {} from '{}'.", args.entry_id, args.collection)
}
_ => "Failed to remove entry.".into(),
}
}
#[tool(
name = "update_tags",
description = "Set tags on a collection. Replaces all existing tags."
)]
async fn update_tags(
&self,
Parameters(args): Parameters<UpdateTagsArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.patch(url(&format!("/api/v1/collections/{col_id}/tags")))
.json(&serde_json::json!({"tags": args.tags}))
.send().await;
match r {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
format!("Updated tags on '{}'.", args.collection)
}
_ => "Failed to update tags.".into(),
}
}
#[tool(
name = "reorder_collection",
description = "Reorder entries in a collection. Provide the entry IDs in the desired order."
)]
async fn reorder_collection(
&self,
Parameters(args): Parameters<ReorderCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.patch(url(&format!("/api/v1/collections/{col_id}/reorder")))
.json(&serde_json::json!({"entry_ids": args.entry_ids}))
.send().await;
match r {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
format!("Reordered entries in '{}'.", args.collection)
}
_ => "Failed to reorder collection.".into(),
}
}
#[tool(
name = "share_collection",
description = "Create a shareable link for a collection. Anyone with the link can view it."
)]
async fn share_collection(
&self,
Parameters(args): Parameters<ShareCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.post(url(&format!("/api/v1/collections/{col_id}/share")))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let data: serde_json::Value = r.json().await.unwrap_or_default();
let share_url = data["url"].as_str().unwrap_or("?");
format!("Shared '{}': {share_url}", args.collection)
}
_ => "Failed to share collection.".into(),
}
}
#[tool(
name = "unshare_collection",
description = "Revoke the shareable link for a collection."
)]
async fn unshare_collection(
&self,
Parameters(args): Parameters<UnshareCollectionArgs>,
) -> String {
let col_id = match self.resolve_collection_id(&args.collection).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.delete(url(&format!("/api/v1/collections/{col_id}/share")))
.send().await;
match r {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
format!("Unshared '{}'.", args.collection)
}
_ => "Failed to unshare collection.".into(),
}
}
#[tool(
name = "merge_collections",
description = "Merge multiple collections into one. All entries are combined, duplicates are skipped."
)]
async fn merge_collections(
&self,
Parameters(args): Parameters<MergeCollectionsArgs>,
) -> String {
if args.collections.len() < 2 {
return "Need at least 2 collection names to merge.".into();
}
let cols: Vec<serde_json::Value> = match self.http.get(url("/api/v1/collections")).send().await {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => return "Failed to list collections.".into(),
};
let mut resolved = Vec::new();
for name in &args.collections {
let Some(col) = cols.iter().find(|c| c["name"].as_str() == Some(name)) else {
return format!("Collection '{name}' not found.");
};
let id = col["id"].as_str().unwrap_or("");
let r = self.http.get(url(&format!("/api/v1/collections/{id}"))).send().await;
match r {
Ok(r) if r.status().is_success() => {
let full: serde_json::Value = r.json().await.unwrap_or_default();
resolved.push(full);
}
_ => return format!("Failed to load collection '{name}'."),
}
}
let r = self.http
.post(url("/api/v1/collections/merge"))
.json(&serde_json::json!({"collections": resolved}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let data: serde_json::Value = r.json().await.unwrap_or_default();
let merged = data["merged"].as_u64().unwrap_or(0);
let created = data["created"].as_u64().unwrap_or(0);
let dupes = data["duplicates_skipped"].as_u64().unwrap_or(0);
format!("Merged: {merged} entries, {created} new, {dupes} duplicates skipped")
}
_ => "Merge failed.".into(),
}
}
#[tool(
name = "batch_move_entries",
description = "Move entries from one collection to another."
)]
async fn batch_move_entries(
&self,
Parameters(args): Parameters<BatchMoveArgs>,
) -> String {
let source_id = match self.resolve_collection_id(&args.source).await {
Ok(id) => id,
Err(e) => return e,
};
let target_id = match self.resolve_collection_id(&args.target).await {
Ok(id) => id,
Err(e) => return e,
};
let r = self.http
.post(url("/api/v1/collections/batch-move"))
.json(&serde_json::json!({
"source_id": source_id,
"target_id": target_id,
"entry_ids": args.entry_ids
}))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let data: serde_json::Value = r.json().await.unwrap_or_default();
let moved = data["moved"].as_u64().unwrap_or(0);
format!("Moved {moved} entries from '{}' to '{}'.", args.source, args.target)
}
_ => "Batch move failed.".into(),
}
}
#[tool(
name = "view_shared",
description = "View a shared collection using its share token."
)]
async fn view_shared(
&self,
Parameters(args): Parameters<ViewSharedArgs>,
) -> String {
let r = self.http
.get(url(&format!("/api/v1/shared/{}", args.share_token)))
.send().await;
match r {
Ok(r) if r.status().is_success() => {
let col: serde_json::Value = r.json().await.unwrap_or_default();
let name = col["name"].as_str().unwrap_or("?");
let entries = col["entries"].as_array().map(|a| a.len()).unwrap_or(0);
let mut out = vec![format!("Shared collection: {name} ({entries} entries)")];
if let Some(arr) = col["entries"].as_array() {
for e in arr.iter().take(20) {
let meta = &e["metadata"];
let title = meta["title"].as_str().unwrap_or("?");
let authors = meta["authors"].as_array().map(|a| {
a.iter().filter_map(|p| p["family"].as_str()).collect::<Vec<_>>().join(", ")
}).unwrap_or_default();
let year = meta["date"]["year"].as_i64().map(|y| format!(" ({y})")).unwrap_or_default();
out.push(format!("- {authors}{year}: {title}"));
}
if entries > 20 {
out.push(format!("... and {} more", entries - 20));
}
}
out.join("\n")
}
Ok(r) if r.status().as_u16() == 404 => "Shared collection not found or link expired.".into(),
_ => "Failed to load shared collection.".into(),
}
}
}
#[tool_handler]
impl ServerHandler for Server {
fn get_info(&self) -> ServerInfo {
let mut caps = ServerCapabilities::default();
caps.tools = Some(ToolsCapability { list_changed: None });
let mut info = ServerInfo::new(caps);
info.server_info.name = "ookcite-mcp".into();
info.server_info.version = env!("CARGO_PKG_VERSION").into();
info.instructions = Some(
"OokCite provides citation METADATA validation and formatting -- it does NOT fetch PDFs, \
full-text articles, or paper content. It returns structured metadata (title, authors, \
year, journal, DOI) and formatted bibliography entries. \
ALWAYS use these tools instead of fetching CrossRef, DOI, or OpenLibrary URLs directly. \
When the user mentions a DOI, ISBN, paper title, citation, or reference: \
use validate_doi to verify DOIs exist before citing them. \
use lookup_isbn for book references. \
use reverse_lookup when given a messy or partial citation string. \
use format_citation to format a DOI in any CSL style (APA, IEEE, Chicago, Nature, etc.). \
use verify_references to batch-check multiple DOIs. \
use batch_format to resolve and format multiple citations at once. \
use search_styles to find CSL style IDs by name. \
use group_cite to generate grouped in-text markers like [1-3]. \
use health_check to verify the API is reachable (use when lookups fail). \
COLLECTION MANAGEMENT: \
use list_collections to see saved citation collections. \
use add_to_collection to save a citation to a named collection (creates if needed). \
use batch_add_to_collection to add multiple citations at once. \
use import_bibliography to import BibTeX or RIS files into a collection. \
use export_collection to get BibTeX for a collection. \
use search_collection to find entries within a collection. \
use check_duplicates to check if a citation already exists in a collection. \
use delete_collection to remove a collection. \
use update_collection to rename or change a collection's default style. \
use remove_from_collection to remove a specific entry. \
use update_tags to set tags on a collection. \
use reorder_collection to change the order of entries. \
SHARING: \
use share_collection to create a shareable link. \
use unshare_collection to revoke sharing. \
use view_shared to view a shared collection by token. \
BULK OPERATIONS: \
use merge_collections to combine multiple collections. \
use batch_move_entries to move entries between collections. \
NEVER fabricate citation metadata -- always validate through these tools first.".into()
);
info
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "setup") {
setup::run(&args[1..]).await;
return Ok(());
}
validate_auth().await;
let server = Server::new();
let service = server.serve(rmcp::transport::io::stdio()).await?;
service.waiting().await?;
Ok(())
}
async fn validate_auth() {
let api_key = match std::env::var("OOKCITE_API_KEY") {
Ok(k) if !k.is_empty() => k,
_ => {
eprintln!(
"ookcite-mcp: anonymous mode (10 lookups/day). \
Set OOKCITE_API_KEY for more."
);
return;
}
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(format!("{API}/api/v1/me"))
.header("authorization", format!("Bearer {api_key}"))
.send()
.await;
#[derive(Deserialize)]
struct MeResponse {
authenticated: bool,
plan: String,
lookups_remaining: u32,
lookups_limit: u32,
}
match resp {
Ok(r) if r.status().is_success() => match r.json::<MeResponse>().await {
Ok(me) if me.authenticated => {
eprintln!(
"ookcite-mcp: {} plan, {}/{} lookups remaining",
me.plan, me.lookups_remaining, me.lookups_limit
);
}
_ => {
eprintln!("ookcite-mcp: WARNING: API key not recognized");
}
},
_ => {
eprintln!("ookcite-mcp: WARNING: could not reach API for key validation");
}
}
}