Skip to main content

research_agent/mcp/
server.rs

1//! The research-agent stdio MCP server (`research serve`).
2//!
3//! Wraps research-agent's application layer as MCP tools so an LLM agent can
4//! drive the whole ingest/query/gaps/report flow. Each `#[tool]` method
5//! calls the application adapters directly; domain data is returned as opaque
6//! `serde_json::Value` (domain types derive `Serialize` but not `JsonSchema`).
7//! Tool bodies mirror the CLI handlers in `src/main.rs` but return JSON instead
8//! of printing to stdout; the two interfaces share the same application layer.
9
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use rmcp::handler::server::wrapper::Parameters;
14use rmcp::model::CallToolResult;
15use rmcp::tool;
16use rmcp::tool_handler;
17use rmcp::tool_router;
18use rmcp::{ServerHandler, ServiceExt};
19use tokio::io::AsyncReadExt;
20
21use super::guard;
22use serde_json::{Value, json};
23
24use crate::adapters::arxiv_source::ArxivSource;
25use crate::adapters::europepmc_source::{EuropePmcSource, PreprintSource};
26use crate::adapters::openalex_source::OpenAlexSource;
27use crate::adapters::pdf_source::PdfSource;
28use crate::adapters::semantic_scholar_source::SemanticScholarSource;
29use crate::adapters::sqlite_store::SqliteStore;
30use crate::application::gap_analyzer::{collect_brief, record_gaps};
31use crate::application::ingest_pipeline::IngestPipeline;
32use crate::application::report_generator::{collect_material, save_report};
33use crate::composition::{load_config, open_store};
34use crate::domain::paper::{Paper, Rating, ReadingStatus};
35use crate::domain::research_topic::ResearchTopic;
36use crate::error::ResearchError;
37use crate::ports::index_store::IndexStore;
38
39use super::params::*;
40
41/// Fixed-at-startup runtime context shared (immutable) across all tool calls.
42pub struct ResearchContext {
43    /// Database path resolved once at startup (default or `--db` override).
44    pub db_path: PathBuf,
45}
46
47/// The MCP server. Holds an immutable `Arc<ResearchContext>`; tool methods
48/// borrow it.
49#[derive(Clone)]
50pub struct ResearchServer {
51    ctx: Arc<ResearchContext>,
52}
53
54impl ResearchServer {
55    pub fn new(ctx: ResearchContext) -> Self {
56        Self { ctx: Arc::new(ctx) }
57    }
58
59    /// Run the stdio MCP server until the client disconnects.
60    ///
61    /// Returns a `String` error (Send + Sync) so the binary entry point can
62    /// surface it via `anyhow`.
63    pub async fn serve_stdio(self) -> Result<(), String> {
64        // Antigravity-style clients probe with non-MCP requests before
65        // `initialize`; rmcp aborts the handshake on those, so consume and
66        // answer them first. `None` = client hung up before handshaking.
67        let Some(first_line) = guard::read_until_forwardable().await else {
68            return Ok(());
69        };
70        // Replay the held line into the transport, then hand stdin over.
71        let stdin = std::io::Cursor::new(first_line).chain(tokio::io::stdin());
72        let service = self
73            .serve((stdin, tokio::io::stdout()))
74            .await
75            .map_err(|e| format!("MCP serve init failed: {e}"))?;
76        service
77            .waiting()
78            .await
79            .map_err(|e| format!("MCP serve stopped: {e}"))?;
80        Ok(())
81    }
82}
83
84// ─── helpers ────────────────────────────────────────────────────────────────
85
86/// Successful tool result carrying a JSON value as a text content block.
87fn ok_value(v: Value) -> CallToolResult {
88    CallToolResult::success(vec![rmcp::model::ContentBlock::text(v.to_string())])
89}
90
91/// Error tool result. Maps research-agent errors to human-readable text; the
92/// MCP layer surfaces this as an `is_error` result the agent can read.
93fn err_result(e: ResearchError) -> CallToolResult {
94    let kind = match &e {
95        ResearchError::NotFound(_) | ResearchError::Duplicate(_) | ResearchError::Validation(_) => {
96            "invalid_params"
97        }
98        ResearchError::Config(_) => "dependency_missing",
99        // arXiv / Semantic Scholar fetch failure: upstream service, not a
100        // local dependency.
101        ResearchError::Source(_) => "upstream_error",
102        _ => "internal_error", // Database, Io, Serialization
103    };
104    CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!(
105        "[{kind}] {e}"
106    ))])
107}
108
109/// Convert a `Result<T>` (where T: Serialize) into a tool result.
110macro_rules! tool_result {
111    ($expr:expr) => {
112        match $expr {
113            Ok(v) => ok_value(serde_json::to_value(&v).unwrap_or(Value::Null)),
114            Err(e) => err_result(e),
115        }
116    };
117}
118
119// ─── ingest helpers ─────────────────────────────────────────────────────────
120
121/// Tool-shaped outcome: either the value or an already-built error response.
122type ToolOutcome<T> = Result<T, CallToolResult>;
123
124/// Fetch and persist papers for the requested source(s). Returns (newly
125/// inserted, all papers now in the library from this fetch, individually
126/// skipped files — PDFs only).
127async fn ingest_papers(
128    store: &SqliteStore,
129    p: &IngestParams,
130) -> ToolOutcome<(Vec<Paper>, Vec<Paper>, usize)> {
131    if p.source == "pdf" {
132        ingest_pdfs(store, p)
133    } else {
134        ingest_remote(store, p).await
135    }
136}
137
138/// Ingest local PDFs, skipping individual unreadable files (counted, not
139/// fatal — reported back in the tool response).
140fn ingest_pdfs(
141    store: &SqliteStore,
142    p: &IngestParams,
143) -> ToolOutcome<(Vec<Paper>, Vec<Paper>, usize)> {
144    let Some(pdf_path) = &p.path else {
145        return Err(err_result(ResearchError::Validation(
146            "path is required for source=pdf".into(),
147        )));
148    };
149    let src = PdfSource::new();
150    let paths = PdfSource::collect_paths(std::path::Path::new(pdf_path)).map_err(err_result)?;
151    let mut papers = Vec::new();
152    let mut skipped = 0usize;
153    for path in &paths {
154        match src.ingest_file(path) {
155            Ok((paper, body)) => {
156                store.insert_paper(&paper).map_err(err_result)?;
157                if let Some(body) = body {
158                    store.set_paper_body(&paper.id, &body).map_err(err_result)?;
159                }
160                papers.push(paper);
161            }
162            Err(_) => skipped += 1,
163        }
164    }
165    let in_library = papers.clone();
166    Ok((papers, in_library, skipped))
167}
168
169/// Query arXiv and/or Semantic Scholar through the ingest pipeline. Remote
170/// sources fail the whole call on error, so nothing is silently skipped.
171/// Returns (newly inserted, all fetched papers now in the library, skipped).
172/// The library-wide list reaches topic linking so a re-run repairs linking a
173/// crashed run missed.
174async fn ingest_remote(
175    store: &SqliteStore,
176    p: &IngestParams,
177) -> ToolOutcome<(Vec<Paper>, Vec<Paper>, usize)> {
178    let Some(q) = &p.query else {
179        return Err(err_result(ResearchError::Validation(format!(
180            "query is required for source={}",
181            p.source
182        ))));
183    };
184    let mut new_papers = Vec::new();
185    let mut in_library = Vec::new();
186    if p.source == "arxiv" || p.source == "all" {
187        let arxiv = ArxivSource::new();
188        let outcome = IngestPipeline::new(&arxiv, store)
189            .run(q, p.limit)
190            .await
191            .map_err(err_result)?;
192        new_papers.extend(outcome.new);
193        in_library.extend(outcome.fetched);
194    }
195    if p.source == "s2" || p.source == "all" {
196        let s2 = SemanticScholarSource::new();
197        let outcome = IngestPipeline::new(&s2, store)
198            .run(q, p.limit)
199            .await
200            .map_err(err_result)?;
201        new_papers.extend(outcome.new);
202        in_library.extend(outcome.fetched);
203    }
204    if p.source == "openalex" || p.source == "all" {
205        let oa = OpenAlexSource::new();
206        let outcome = IngestPipeline::new(&oa, store)
207            .run(q, p.limit)
208            .await
209            .map_err(err_result)?;
210        new_papers.extend(outcome.new);
211        in_library.extend(outcome.fetched);
212    }
213    if p.source == "europepmc" || p.source == "all" {
214        let epmc = EuropePmcSource::new();
215        let outcome = IngestPipeline::new(&epmc, store)
216            .run(q, p.limit)
217            .await
218            .map_err(err_result)?;
219        new_papers.extend(outcome.new);
220        in_library.extend(outcome.fetched);
221    }
222    if p.source == "preprints" || p.source == "all" {
223        let pre = PreprintSource::new();
224        let outcome = IngestPipeline::new(&pre, store)
225            .run(q, p.limit)
226            .await
227            .map_err(err_result)?;
228        new_papers.extend(outcome.new);
229        in_library.extend(outcome.fetched);
230    }
231    Ok((new_papers, in_library, 0))
232}
233
234/// Link every ingested paper to the requested topic. A missing topic is an
235/// error, matching the CLI behavior.
236fn link_ingested_to_topic(
237    store: &SqliteStore,
238    papers: &[Paper],
239    topic: &Option<String>,
240) -> ToolOutcome<()> {
241    let Some(topic_id) = topic else {
242        return Ok(());
243    };
244    match store.get_topic(topic_id) {
245        Ok(Some(_)) => {}
246        Ok(None) => {
247            return Err(err_result(ResearchError::NotFound(format!(
248                "topic '{topic_id}'"
249            ))));
250        }
251        Err(e) => return Err(err_result(e)),
252    }
253    for paper in papers {
254        store
255            .link_paper_to_topic(&paper.id, topic_id, paper.relevance_score)
256            .map_err(err_result)?;
257    }
258    Ok(())
259}
260
261// ─── tools ──────────────────────────────────────────────────────────────────
262
263#[tool_router]
264impl ResearchServer {
265    #[tool(
266        description = "Initialize a research workspace: create the SQLite index schema and default config if missing. Returns the db path."
267    )]
268    pub fn init(&self) -> CallToolResult {
269        let db_path = self.ctx.db_path.clone();
270        // Materialize default config if absent (load_config writes it).
271        if let Err(e) = load_config() {
272            return err_result(e);
273        }
274        match open_store(&db_path) {
275            Ok(store) => match store.init_schema() {
276                Ok(()) => ok_value(json!({
277                    "initialized": true,
278                    "db": db_path.to_string_lossy(),
279                })),
280                Err(e) => err_result(e),
281            },
282            Err(e) => err_result(e),
283        }
284    }
285
286    #[tool(
287        description = "Ingest papers from arXiv, Semantic Scholar, OpenAlex, Europe PMC (PubMed), bioRxiv-style preprints, or local PDFs. source: arxiv|s2|openalex|europepmc|preprints|all|pdf. For arxiv/s2/openalex/europepmc/preprints/all a query is required; for pdf a path (file or dir) is required. Optionally link ingested papers to a topic. Network-heavy for remote sources (async)."
288    )]
289    pub async fn ingest(&self, Parameters(p): Parameters<IngestParams>) -> CallToolResult {
290        let store = match open_store(&self.ctx.db_path) {
291            Ok(s) => s,
292            Err(e) => return err_result(e),
293        };
294        let (new_papers, all_papers, skipped) = match ingest_papers(&store, &p).await {
295            Ok((new_papers, all_papers, skipped)) => (new_papers, all_papers, skipped),
296            Err(resp) => return resp,
297        };
298        if let Err(resp) = link_ingested_to_topic(&store, &all_papers, &p.topic) {
299            return resp;
300        }
301
302        ok_value(json!({
303            "ingested": new_papers.len(),
304            "in_library": all_papers.len(),
305            "skipped": skipped,
306            "source": p.source,
307            "linked_topic": p.topic,
308            "papers": serde_json::to_value(&all_papers).unwrap_or(Value::Null),
309        }))
310    }
311
312    #[tool(description = "Force a full rebuild of the FTS search index.")]
313    pub fn index_rebuild(&self) -> CallToolResult {
314        let store = match open_store(&self.ctx.db_path) {
315            Ok(s) => s,
316            Err(e) => return err_result(e),
317        };
318        match store.rebuild_index() {
319            Ok(()) => ok_value(json!({"rebuilt": true})),
320            Err(e) => err_result(e),
321        }
322    }
323
324    #[tool(
325        description = "Import papers from BibTeX/BibLaTeX (.bib) or CSL-JSON (.json) files — e.g. a Zotero export. path is a file or a directory (all matching files are imported). Papers with a DOI already in the library are skipped."
326    )]
327    pub fn import_papers(&self, Parameters(p): Parameters<ImportPapersParams>) -> CallToolResult {
328        let store = match open_store(&self.ctx.db_path) {
329            Ok(s) => s,
330            Err(e) => return err_result(e),
331        };
332        match crate::application::paper_import::run_import(&store, std::path::Path::new(&p.path)) {
333            Ok(summary) => ok_value(json!({
334                "imported": summary.imported.len(),
335                "skipped_duplicates": summary.skipped_duplicates,
336                "failed": summary.failed,
337                "papers": serde_json::to_value(&summary.imported).unwrap_or(Value::Null),
338            })),
339            Err(e) => err_result(e),
340        }
341    }
342
343    #[tool(
344        description = "Fetch the stored full body text of a paper (from PDF ingest), section headings marked with '## ' and page boundaries with '<!-- page N -->'. Pass query to get matching snippets with their section and page instead of the whole body — far cheaper on context. Returns has_body=false if only metadata is stored."
345    )]
346    pub fn paper_body(&self, Parameters(p): Parameters<PaperBodyParams>) -> CallToolResult {
347        let store = match open_store(&self.ctx.db_path) {
348            Ok(s) => s,
349            Err(e) => return err_result(e),
350        };
351        // Query mode returns located evidence rather than a wall of text.
352        if let Some(query) = p.query.as_deref().filter(|q| !q.trim().is_empty()) {
353            return match store.search_body_evidence(query, Some(&p.id), 20) {
354                Ok(matches) => ok_value(json!({
355                    "id": p.id,
356                    "query": query,
357                    "matches": matches,
358                })),
359                Err(e) => err_result(e),
360            };
361        }
362        match store.get_paper_body(&p.id) {
363            Ok(Some(body)) => {
364                // Cap what flows into an agent's context window; the full text
365                // stays in the DB (`research read <id> --body` prints it all).
366                const MAX_TOOL_BODY_CHARS: usize = 40_000;
367                let truncated = body.chars().take(MAX_TOOL_BODY_CHARS).collect::<String>();
368                ok_value(json!({
369                    "id": p.id,
370                    "has_body": true,
371                    "truncated": truncated.chars().count() < body.chars().count(),
372                    "text": truncated,
373                }))
374            }
375            Ok(None) => ok_value(json!({ "id": p.id, "has_body": false })),
376            Err(e) => err_result(e),
377        }
378    }
379
380    #[tool(
381        description = "Fetch and store citation-graph edges for a paper via OpenAlex, ingesting newly seen related papers into the library. direction \"references\" (default) lists the works the paper cites; \"cited_by\" lists the works citing it. Set intents=true to additionally label edges with Semantic Scholar citation intents (background/methodology/result, influential); S2 classifies only a fraction of edges, so partial labeling is normal. Requires the paper to have an OpenAlex id or DOI. Idempotent. Network-heavy (async)."
382    )]
383    pub async fn paper_references(
384        &self,
385        Parameters(p): Parameters<PaperReferencesParams>,
386    ) -> CallToolResult {
387        let cited_by = match p.direction.as_deref() {
388            None | Some("references") => false,
389            Some("cited_by") => true,
390            Some(other) => {
391                return err_result(ResearchError::Source(format!(
392                    "unknown direction '{other}'; use \"references\" or \"cited_by\""
393                )));
394            }
395        };
396        let store = match open_store(&self.ctx.db_path) {
397            Ok(s) => s,
398            Err(e) => return err_result(e),
399        };
400        let paper = match store.get_paper(&p.id) {
401            Ok(Some(paper)) => paper,
402            Ok(None) => {
403                return err_result(ResearchError::NotFound(format!(
404                    "paper '{}' not found",
405                    p.id
406                )));
407            }
408            Err(e) => return err_result(e),
409        };
410        let synced = if cited_by {
411            crate::application::references::sync_cited_by(&store, &paper).await
412        } else {
413            crate::application::references::sync_references(&store, &paper).await
414        };
415        match synced {
416            Ok((papers, new_edges, new_papers)) => {
417                // Opt-in second pass: labels only edges the graph already
418                // holds, and spends its own rate-limited S2 request. A
419                // failure here leaves edges unlabeled rather than failing the
420                // whole call.
421                let intent_counts = if p.intents.unwrap_or(false) {
422                    crate::application::references::sync_citation_intents(&store, &paper, cited_by)
423                        .await
424                        .ok()
425                } else {
426                    None
427                };
428                let edges = if cited_by {
429                    store.citations_citing_paper(&p.id)
430                } else {
431                    store.citations_for_paper(&p.id)
432                };
433                match edges {
434                    Ok(edges) => ok_value(json!({
435                        "id": p.id,
436                        "direction": if cited_by { "cited_by" } else { "references" },
437                        "edges": edges.len(),
438                        "new_edges": new_edges,
439                        "new_papers_count": new_papers,
440                        // Present only when intents were requested; null when
441                        // the S2 pass was skipped or failed.
442                        "intents_labeled": intent_counts.map(|(labeled, _)| labeled),
443                        "intents_unlabeled": intent_counts.map(|(_, unlabeled)| unlabeled),
444                        // Per-edge labels, empty string when unclassified.
445                        "edge_contexts": edges.iter().map(|e| json!({
446                            "citing": e.citing_paper_id,
447                            "cited": e.cited_paper_id,
448                            "context": e.context,
449                        })).collect::<Vec<_>>(),
450                        // Resolved related papers (metadata only); already-known
451                        // entries are included so the agent can see the full list.
452                        "related_papers": papers,
453                    })),
454                    Err(e) => err_result(e),
455                }
456            }
457            Err(e) => err_result(e),
458        }
459    }
460
461    #[tool(
462        description = "Search the local paper index by query (FTS5 full-text over title, abstract, notes, tags, keywords, and body). Returns matching papers (id, title, authors, year, status). Recall for paraphrased queries depends on stored keywords — see papers_missing_keywords and enrich_paper."
463    )]
464    pub fn query_papers(&self, Parameters(p): Parameters<QueryPapersParams>) -> CallToolResult {
465        let store = match open_store(&self.ctx.db_path) {
466            Ok(s) => s,
467            Err(e) => return err_result(e),
468        };
469        let results = store.search_papers(&p.query, p.limit);
470        tool_result!(results)
471    }
472
473    #[tool(
474        description = "Papers that have no search keywords yet. For each, read its title and abstract, generate 5-10 English keywords (synonyms, expanded acronyms, alternative phrasings a searcher might use that the abstract does not contain), then store them with enrich_paper. This is what makes paraphrased queries findable."
475    )]
476    pub fn papers_missing_keywords(
477        &self,
478        Parameters(p): Parameters<MissingKeywordsParams>,
479    ) -> CallToolResult {
480        let store = match open_store(&self.ctx.db_path) {
481            Ok(s) => s,
482            Err(e) => return err_result(e),
483        };
484        // Clamped: the param is client-supplied and each row carries a full
485        // abstract into the JSON response.
486        let results = store.papers_missing_keywords(p.limit.min(200));
487        tool_result!(results)
488    }
489
490    #[tool(
491        description = "Store search keywords for one paper, e.g. \"transformer; self-attention; sequence modeling\". Overwrites any previous value and reindexes the paper for full-text search."
492    )]
493    pub fn enrich_paper(&self, Parameters(p): Parameters<EnrichPaperParams>) -> CallToolResult {
494        let store = match open_store(&self.ctx.db_path) {
495            Ok(s) => s,
496            Err(e) => return err_result(e),
497        };
498        match store.set_paper_keywords(&p.id, &p.keywords) {
499            Ok(()) => ok_value(json!({"id": p.id, "keywords": p.keywords})),
500            Err(e) => err_result(e),
501        }
502    }
503
504    #[tool(
505        description = "Collect a topic brief: the topic, its papers with reading status, recorded gaps, and coverage state. Analyze this data yourself, then persist your findings with gaps_record."
506    )]
507    pub fn topic_brief(&self, Parameters(p): Parameters<TopicBriefParams>) -> CallToolResult {
508        let store = match open_store(&self.ctx.db_path) {
509            Ok(s) => s,
510            Err(e) => return err_result(e),
511        };
512        tool_result!(collect_brief(&store, &p.topic))
513    }
514
515    #[tool(
516        description = "Record knowledge gaps you identified for a topic. Each gap is {description, gap_type?, priority?} where gap_type is one of missing_literature | unanswered_question | methodology_gap | connection_gap and priority is 0..=1."
517    )]
518    pub fn gaps_record(&self, Parameters(p): Parameters<GapsRecordParams>) -> CallToolResult {
519        let store = match open_store(&self.ctx.db_path) {
520            Ok(s) => s,
521            Err(e) => return err_result(e),
522        };
523        let gaps: Vec<(String, Option<String>, Option<f32>)> = p
524            .gaps
525            .into_iter()
526            .map(|g| (g.description, g.gap_type, g.priority))
527            .collect();
528        tool_result!(record_gaps(&store, &p.topic, &gaps))
529    }
530
531    #[tool(description = "List recorded knowledge gaps, optionally filtered by topic id.")]
532    pub fn list_gaps(&self, Parameters(p): Parameters<ListGapsParams>) -> CallToolResult {
533        let store = match open_store(&self.ctx.db_path) {
534            Ok(s) => s,
535            Err(e) => return err_result(e),
536        };
537        tool_result!(store.list_gaps(p.topic.as_deref()))
538    }
539
540    #[tool(
541        description = "Collect source material for a report: topic briefs (papers, gaps, coverage) for comma-separated topic ids. Draft the markdown yourself, then store it with report_save."
542    )]
543    pub fn report_material(&self, Parameters(p): Parameters<ReportTopicParams>) -> CallToolResult {
544        let store = match open_store(&self.ctx.db_path) {
545            Ok(s) => s,
546            Err(e) => return err_result(e),
547        };
548        let topic_ids: Vec<String> = p.topics.split(',').map(|t| t.trim().to_string()).collect();
549        tool_result!(collect_material(&store, &topic_ids))
550    }
551
552    #[tool(
553        description = "Store an agent-authored markdown report. Sections are split on '## ' headings. Returns the report id and metadata."
554    )]
555    pub fn report_save(&self, Parameters(p): Parameters<ReportSaveParams>) -> CallToolResult {
556        let store = match open_store(&self.ctx.db_path) {
557            Ok(s) => s,
558            Err(e) => return err_result(e),
559        };
560        let topic_ids: Vec<String> = p.topics.split(',').map(|t| t.trim().to_string()).collect();
561        match save_report(&store, &p.title, &topic_ids, &p.markdown) {
562            Ok(report) => ok_value(json!({
563                "id": report.id,
564                "title": report.title,
565                "sections": report.sections.len(),
566                "markdown": report.to_markdown(),
567            })),
568            Err(e) => err_result(e),
569        }
570    }
571
572    #[tool(description = "List all research topics with their hierarchy depth.")]
573    pub fn topics_list(&self) -> CallToolResult {
574        let store = match open_store(&self.ctx.db_path) {
575            Ok(s) => s,
576            Err(e) => return err_result(e),
577        };
578        tool_result!(store.list_topics())
579    }
580
581    #[tool(description = "Add a research topic, optionally as a sub-topic of a parent.")]
582    pub fn topic_add(&self, Parameters(p): Parameters<TopicAddParams>) -> CallToolResult {
583        let store = match open_store(&self.ctx.db_path) {
584            Ok(s) => s,
585            Err(e) => return err_result(e),
586        };
587        let mut topic = match &p.parent {
588            Some(parent_id) => match store.get_topic(parent_id) {
589                Ok(Some(parent)) => ResearchTopic::new_subtopic(p.name, &parent),
590                Ok(None) => {
591                    return err_result(ResearchError::NotFound(format!(
592                        "parent topic '{parent_id}'"
593                    )));
594                }
595                Err(e) => return err_result(e),
596            },
597            None => ResearchTopic::new(p.name),
598        };
599        topic.description = p.description;
600        let id = topic.id.clone();
601        let depth = topic.depth;
602        match store.insert_topic(&topic) {
603            Ok(()) => ok_value(json!({ "id": id, "depth": depth })),
604            Err(e) => err_result(e),
605        }
606    }
607
608    #[tool(
609        description = "Research state overview: counts of topics/papers/gaps plus per-topic coverage."
610    )]
611    pub fn state(&self) -> CallToolResult {
612        let store = match open_store(&self.ctx.db_path) {
613            Ok(s) => s,
614            Err(e) => return err_result(e),
615        };
616        let topics = match store.list_topics() {
617            Ok(t) => t,
618            Err(e) => return err_result(e),
619        };
620        let papers = match store.list_papers(None) {
621            Ok(p) => p,
622            Err(e) => return err_result(e),
623        };
624        let gaps = match store.list_gaps(None) {
625            Ok(g) => g,
626            Err(e) => return err_result(e),
627        };
628
629        let read = papers
630            .iter()
631            .filter(|p| p.reading_status == ReadingStatus::Completed)
632            .count();
633        let queued = papers
634            .iter()
635            .filter(|p| p.reading_status == ReadingStatus::Queued)
636            .count();
637        let rated = papers.iter().filter(|p| p.rating.is_some()).count();
638
639        let per_topic: Vec<Value> = topics
640            .iter()
641            .map(|t| {
642                let state = store.get_research_state(&t.id).ok().flatten();
643                json!({
644                    "name": t.name,
645                    "state": state,
646                })
647            })
648            .collect();
649
650        ok_value(json!({
651            "topics": topics.len(),
652            "papers": papers.len(),
653            "gaps": gaps.len(),
654            "read": read,
655            "queued": queued,
656            "rated": rated,
657            "per_topic": per_topic,
658        }))
659    }
660
661    #[tool(
662        description = "Update reading status and/or 1–5 rating of a paper. With neither set, returns the current paper."
663    )]
664    pub fn update_read(&self, Parameters(p): Parameters<UpdateReadParams>) -> CallToolResult {
665        let store = match open_store(&self.ctx.db_path) {
666            Ok(s) => s,
667            Err(e) => return err_result(e),
668        };
669
670        // Validate rating bounds before any side effect.
671        let rating = match p.rating {
672            Some(r) => Some(match Rating::new(r) {
673                Ok(rating) => rating,
674                Err(e) => return err_result(e),
675            }),
676            None => None,
677        };
678
679        if let Some(status) = &p.status
680            && let Err(e) =
681                store.update_reading_status(&p.id, ReadingStatus::from_str_lossy(status))
682        {
683            return err_result(e);
684        }
685        if let Some(rating) = rating
686            && let Err(e) = store.update_rating(&p.id, rating)
687        {
688            return err_result(e);
689        }
690
691        if p.status.is_none() && rating.is_none() {
692            // Lookup-only: an unknown id is an error, not a null result.
693            return match store.get_paper(&p.id) {
694                Ok(Some(paper)) => ok_value(serde_json::to_value(&paper).unwrap_or(Value::Null)),
695                Ok(None) => err_result(ResearchError::NotFound(format!("paper '{}'", p.id))),
696                Err(e) => err_result(e),
697            };
698        }
699        ok_value(json!({ "updated": p.id, "status": p.status, "rating": p.rating }))
700    }
701}
702
703#[tool_handler]
704impl ServerHandler for ResearchServer {
705    fn get_info(&self) -> rmcp::model::ServerInfo {
706        let mut info = rmcp::model::ServerInfo::default();
707        info.server_info = rmcp::model::Implementation::new("research", env!("CARGO_PKG_VERSION"));
708        // Hosts only load tools the server advertises; without this the
709        // capabilities object serializes empty and every client sees 0 tools.
710        info.capabilities = rmcp::model::ServerCapabilities::builder()
711            .enable_tools()
712            .build();
713        info.instructions = Some(
714            "research-agent: personal academic research memory. Drive the flow: \
715             init, ingest, query_papers, topic_brief, gaps_record, then report_material and report_save. \
716             Organize with topics_list/topic_add, check the overview with state, \
717             and track progress with update_read."
718                .into(),
719        );
720        info
721    }
722}