use std::path::PathBuf;
use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
use rmcp::model::{ServerCapabilities, ServerInfo};
use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::io::{stdin, stdout};
use contextgrep::error::{Result as ScoutResult, SearchError};
pub fn run_mcp_server(index_override: Option<PathBuf>) -> ScoutResult<()> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| SearchError::Mmap(e.to_string()))?;
rt.block_on(async {
let server = ScoutMcp::new(index_override);
let service = server
.serve((stdin(), stdout()))
.await
.map_err(|e| SearchError::Mmap(e.to_string()))?;
service
.waiting()
.await
.map_err(|e| SearchError::Mmap(e.to_string()))?;
Ok(())
})
}
#[derive(Clone)]
struct ScoutMcp {
binary: String,
index: Option<PathBuf>,
tool_router: ToolRouter<Self>,
}
impl ScoutMcp {
fn new(index: Option<PathBuf>) -> Self {
let binary = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_owned()))
.unwrap_or_else(|| "contextgrep".to_owned());
Self {
binary,
index,
tool_router: Self::tool_router(),
}
}
fn cmd(&self, subcommand: &str) -> std::process::Command {
let mut c = std::process::Command::new(&self.binary);
if let Some(idx) = &self.index {
c.arg("--index").arg(idx);
}
c.arg(subcommand);
c
}
fn run(&self, mut cmd: std::process::Command) -> String {
match cmd.output() {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() && stdout.is_empty() {
format!("error: {}", stderr.trim())
} else {
stdout
}
}
Err(e) => format!("error: failed to run contextgrep: {e}"),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for ScoutMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.build(),
)
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct IndexInput {
path: String,
#[serde(default)]
full: bool,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SearchInput {
query: String,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default = "default_context")]
context_size: usize,
#[serde(default)]
full_content: bool,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct QueryInput {
dsl: String,
#[serde(default = "default_limit")]
limit: usize,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SimilarInput {
file: String,
threshold: Option<u32>,
#[serde(default = "default_limit")]
limit: usize,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct RecentInput {
#[serde(default = "default_since")]
since: String,
#[serde(default = "default_recent_limit")]
limit: usize,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ClustersInput {
path: Option<String>,
#[serde(default = "default_bits")]
bits: u32,
}
fn default_limit() -> usize { 10 }
fn default_context() -> usize { 120 }
fn default_since() -> String { "7d".into() }
fn default_recent_limit() -> usize { 20 }
fn default_bits() -> u32 { 4 }
#[tool_router(router = tool_router)]
impl ScoutMcp {
#[tool(description = "Index a folder. Must be run before search/query/similar/recent/clusters.")]
async fn index(&self, Parameters(input): Parameters<IndexInput>) -> String {
let mut cmd = self.cmd("index");
cmd.arg(&input.path);
if input.full {
cmd.arg("--full");
}
self.run(cmd)
}
#[tool(description = "Trigram fuzzy search. Returns scored JSON results with snippets or full content.")]
async fn search(&self, Parameters(input): Parameters<SearchInput>) -> String {
let mut cmd = self.cmd("search");
cmd.arg(&input.query)
.arg("-n").arg(input.limit.to_string())
.arg("--context-size").arg(input.context_size.to_string())
.arg("--output").arg("json");
if input.full_content {
cmd.arg("--full-content");
}
self.run(cmd)
}
#[tool(description = "Structured DSL search. Filters: type:contract, amount:>1M, date:>2024-01-01, path:/legal, AND/OR/NOT.")]
async fn query(&self, Parameters(input): Parameters<QueryInput>) -> String {
let mut cmd = self.cmd("query");
cmd.arg(&input.dsl)
.arg("-n").arg(input.limit.to_string())
.arg("--output").arg("json");
self.run(cmd)
}
#[tool(description = "Find documents similar to a given file using SimHash fingerprinting.")]
async fn similar(&self, Parameters(input): Parameters<SimilarInput>) -> String {
let mut cmd = self.cmd("similar");
cmd.arg(&input.file)
.arg("-n").arg(input.limit.to_string())
.arg("--output").arg("json");
if let Some(t) = input.threshold {
cmd.arg("--threshold").arg(t.to_string());
}
self.run(cmd)
}
#[tool(description = "List recently modified indexed documents within a time window.")]
async fn recent(&self, Parameters(input): Parameters<RecentInput>) -> String {
let mut cmd = self.cmd("recent");
cmd.arg("--since").arg(&input.since)
.arg("-n").arg(input.limit.to_string())
.arg("--output").arg("json");
self.run(cmd)
}
#[tool(description = "Group indexed documents into similarity clusters using SimHash LSH.")]
async fn clusters(&self, Parameters(input): Parameters<ClustersInput>) -> String {
let mut cmd = self.cmd("clusters");
cmd.arg("--bits").arg(input.bits.to_string())
.arg("--output").arg("json");
if let Some(p) = &input.path {
cmd.arg(p);
}
self.run(cmd)
}
}