1use 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
41pub struct ResearchContext {
43 pub db_path: PathBuf,
45}
46
47#[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 pub async fn serve_stdio(self) -> Result<(), String> {
64 let Some(first_line) = guard::read_until_forwardable().await else {
68 return Ok(());
69 };
70 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
84fn ok_value(v: Value) -> CallToolResult {
88 CallToolResult::success(vec![rmcp::model::ContentBlock::text(v.to_string())])
89}
90
91fn 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 ResearchError::Source(_) => "upstream_error",
102 _ => "internal_error", };
104 CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!(
105 "[{kind}] {e}"
106 ))])
107}
108
109macro_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
119type ToolOutcome<T> = Result<T, CallToolResult>;
123
124async 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
138fn 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
169async 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
234fn 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#[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 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 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 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 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 "intents_labeled": intent_counts.map(|(labeled, _)| labeled),
443 "intents_unlabeled": intent_counts.map(|(_, unlabeled)| unlabeled),
444 "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 "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 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 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 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 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}