use rmcp::model::{CallToolResult, Content};
use rmcp::ErrorData as McpError;
use super::tools::*;
use crate::http::client::Client;
pub(super) struct RemoteBackend {
client: Client,
url: String,
role: String,
}
mod params;
#[cfg(test)]
#[path = "params_tests.rs"]
mod params_tests;
use params::*;
impl RemoteBackend {
pub(super) fn new(url: String, role: String) -> Self {
let client = Client::new(&url);
let url = client.base_url().to_string();
Self { client, url, role }
}
pub(super) fn url(&self) -> &str {
&self.url
}
fn role_param(&self, params: Params) -> Params {
params.set("role", &self.role)
}
fn ok(value: serde_json::Value) -> Result<CallToolResult, McpError> {
let json = serde_json::to_string_pretty(&value)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
fn fail(&self, tool: &str, err: anyhow::Error) -> McpError {
McpError::internal_error(
format!(
"bobbin {} could not reach the configured server {}: {:#}",
tool, self.url, err
),
None,
)
}
async fn get(
&self,
tool: &str,
path: &str,
params: Params,
) -> Result<CallToolResult, McpError> {
match self.client.get_value(path, ¶ms.into_vec()).await {
Ok(value) => Self::ok(value),
Err(e) => Err(self.fail(tool, e)),
}
}
async fn post(
&self,
tool: &str,
path: &str,
body: serde_json::Value,
) -> Result<CallToolResult, McpError> {
match self.client.post_value(path, &body).await {
Ok(value) => Self::ok(value),
Err(e) => Err(self.fail(tool, e)),
}
}
pub(super) async fn search(&self, req: &SearchRequest) -> Result<CallToolResult, McpError> {
let p = search_params(req);
self.get("search", "/search", self.role_param(p)).await
}
pub(super) async fn grep(&self, req: &GrepRequest) -> Result<CallToolResult, McpError> {
let p = grep_params(req);
self.get("grep", "/grep", self.role_param(p)).await
}
pub(super) async fn context(&self, req: &ContextRequest) -> Result<CallToolResult, McpError> {
let p = context_params(req);
self.get("context", "/context", self.role_param(p)).await
}
pub(super) async fn read_chunk(
&self,
req: &ReadChunkRequest,
) -> Result<CallToolResult, McpError> {
let p = read_chunk_params(req);
self.get("read_chunk", "/read", p).await
}
pub(super) async fn related(&self, req: &RelatedRequest) -> Result<CallToolResult, McpError> {
let p = related_params(req);
self.get("related", "/related", self.role_param(p)).await
}
pub(super) async fn find_refs(
&self,
req: &FindRefsRequest,
) -> Result<CallToolResult, McpError> {
let p = find_refs_params(req);
self.get("find_refs", "/refs", self.role_param(p)).await
}
pub(super) async fn list_symbols(
&self,
req: &ListSymbolsRequest,
) -> Result<CallToolResult, McpError> {
let p = list_symbols_params(req);
self.get("list_symbols", "/symbols", self.role_param(p))
.await
}
pub(super) async fn dependencies(
&self,
req: &DependenciesRequest,
) -> Result<CallToolResult, McpError> {
let p = dependencies_params(req);
self.get("dependencies", "/deps", self.role_param(p)).await
}
pub(super) async fn file_history(
&self,
req: &FileHistoryRequest,
) -> Result<CallToolResult, McpError> {
let p = file_history_params(req);
self.get("file_history", "/history", self.role_param(p))
.await
}
pub(super) async fn hotspots(&self, req: &HotspotsRequest) -> Result<CallToolResult, McpError> {
let p = hotspots_params(req);
self.get("hotspots", "/hotspots", self.role_param(p)).await
}
pub(super) async fn impact(&self, req: &ImpactRequest) -> Result<CallToolResult, McpError> {
let p = impact_params(req);
self.get("impact", "/impact", self.role_param(p)).await
}
pub(super) async fn similar(&self, req: &SimilarRequest) -> Result<CallToolResult, McpError> {
let p = similar_params(req);
self.get("similar", "/similar", self.role_param(p)).await
}
pub(super) fn review_unavailable(&self) -> McpError {
McpError::invalid_params(
format!(
"review inspects the working tree of the machine it runs on, and this MCP \
server is proxying to {}. The remote server would diff ITS checkout, not \
yours. Run `bobbin review` in your repo, or run this MCP server against a \
local index (`bobbin init` + `bobbin index`, then `bobbin serve --no-remote`).",
self.url
),
None,
)
}
pub(super) async fn search_beads(
&self,
req: &SearchBeadsRequest,
) -> Result<CallToolResult, McpError> {
let p = search_beads_params(req);
self.get("search_beads", "/beads", p).await
}
pub(super) async fn archive_search(
&self,
req: &ArchiveSearchRequest,
) -> Result<CallToolResult, McpError> {
let p = archive_search_params(req);
self.get("archive_search", "/archive/search", p).await
}
pub(super) async fn archive_recent(
&self,
req: &ArchiveRecentRequest,
) -> Result<CallToolResult, McpError> {
let p = archive_recent_params(req);
self.get("archive_recent", "/archive/recent", p).await
}
pub(super) async fn prime(&self, req: &PrimeRequest) -> Result<CallToolResult, McpError> {
let p = prime_params(req);
self.get("prime", "/prime", p).await
}
pub(super) async fn status(&self, req: &StatusRequest) -> Result<CallToolResult, McpError> {
if req.repo.is_some() {
return Err(McpError::invalid_params(
format!(
"status has no per-repo filter on a remote server ({}) — it reports the \
whole index. Use the `search` tool with repo=<name>, or the server's \
/repos endpoint, for per-repo figures.",
self.url
),
None,
));
}
self.get("status", "/status", Params::new()).await
}
pub(super) async fn feedback_submit(
&self,
req: &FeedbackSubmitRequest,
agent: &str,
) -> Result<CallToolResult, McpError> {
let body = serde_json::json!({
"injection_id": req.injection_id,
"rating": req.rating,
"reason": req.reason.clone().unwrap_or_default(),
"agent": agent,
});
self.post("feedback_submit", "/feedback", body).await
}
pub(super) async fn feedback_list(
&self,
req: &FeedbackListRequest,
) -> Result<CallToolResult, McpError> {
let p = feedback_list_params(req);
self.get("feedback_list", "/feedback", p).await
}
pub(super) async fn feedback_stats(
&self,
req: &FeedbackStatsRequest,
) -> Result<CallToolResult, McpError> {
let p = Params::new().opt_str("group_by", &req.group_by);
self.get("feedback_stats", "/feedback/stats", p).await
}
pub(super) async fn feedback_lineage_store(
&self,
req: &FeedbackLineageStoreRequest,
agent: &str,
) -> Result<CallToolResult, McpError> {
let body = serde_json::json!({
"feedback_ids": req.feedback_ids,
"action_type": req.action_type,
"bead": req.bead,
"commit_hash": req.commit_hash,
"description": req.description,
"agent": agent,
});
self.post("feedback_lineage_store", "/feedback/lineage", body)
.await
}
pub(super) async fn feedback_lineage_list(
&self,
req: &FeedbackLineageListRequest,
) -> Result<CallToolResult, McpError> {
let p = feedback_lineage_list_params(req);
self.get("feedback_lineage_list", "/feedback/lineage", p)
.await
}
pub(super) async fn stats_json(&self) -> anyhow::Result<String> {
let value = self.client.get_value("/status", &[]).await?;
let index = value.get("index").cloned().unwrap_or(value);
Ok(serde_json::to_string_pretty(&index)?)
}
pub(super) fn local_only(&self, tool: &str, why: &str) -> McpError {
McpError::invalid_params(
format!(
"{} needs a local bobbin index: {}. This MCP server is proxying to {}, \
which has no endpoint for it. Run `bobbin init` + `bobbin index` here and \
start the server with `bobbin serve --no-remote`, or use a tool that the \
remote server does serve (search, grep, context, find_refs, list_symbols, \
related, dependencies, file_history, hotspots, impact, similar, read_chunk, \
search_beads, archive_search, archive_recent, prime, status).",
tool, why, self.url
),
None,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_resolved_role_reaches_the_server() {
let backend = RemoteBackend::new("http://search.example/".into(), "aegis/crew/x".into());
let p = backend.role_param(Params::new());
assert_eq!(p.get("role"), Some("aegis/crew/x"));
}
#[test]
fn the_base_url_loses_its_trailing_slash() {
let backend = RemoteBackend::new("http://search.example/".into(), "default".into());
assert_eq!(backend.url(), "http://search.example");
}
#[test]
fn status_refuses_a_repo_filter_rather_than_ignoring_it() {
let backend = RemoteBackend::new("http://search.example".into(), "default".into());
let err = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(backend.status(&StatusRequest {
detailed: None,
repo: Some("bobbin".into()),
}))
.unwrap_err();
assert!(
err.message.contains("no per-repo filter"),
"unexpected message: {}",
err.message
);
}
#[test]
fn a_local_only_tool_names_itself_and_the_remedy() {
let backend = RemoteBackend::new("http://search.example".into(), "default".into());
let err = backend.local_only("test_coverage", "it reads local git history");
assert!(err.message.contains("test_coverage"), "{}", err.message);
assert!(err.message.contains("--no-remote"), "{}", err.message);
assert!(
err.message.contains("http://search.example"),
"{}",
err.message
);
}
}