pub mod read;
pub mod run;
pub mod types;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::CallToolResult;
use rmcp::{ServerHandler, ServiceExt, tool, tool_handler, tool_router, transport::stdio};
use serde::Deserialize;
use serde_json::json;
use types::error_class;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct HealthArgs {
#[serde(default)]
pub repo: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StateArgs {
pub repo: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RunReviewArgs {
pub repo: String,
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub profile: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct VerdictArgs {
pub repo: String,
#[serde(default)]
pub run_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindingsArgs {
pub repo: String,
#[serde(default)]
pub run_id: Option<String>,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadArtifactArgs {
pub repo: String,
pub run_id: String,
pub artifact: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Clone)]
pub struct PrviewMcp {
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
}
impl Default for PrviewMcp {
fn default() -> Self {
Self::new()
}
}
#[tool_router]
impl PrviewMcp {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
name = "health",
description = "Call once at session start to confirm prview is operational."
)]
async fn health(&self, Parameters(args): Parameters<HealthArgs>) -> CallToolResult {
let git_ok = crate::git::git_cmd()
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let deps_repo = match args.repo.as_deref() {
Some(repo) => match read::resolve_repo_root(repo) {
Ok(root) => match crate::config::Config::for_state_viewer(&root) {
Ok(config) => {
let kind = config.profile.kind;
json!({
"profile": kind.as_str(),
"tools": profile_tool_availability(kind),
})
}
Err(_) => serde_json::Value::Null,
},
Err(_) => serde_json::Value::Null,
},
None => serde_json::Value::Null,
};
types::tool_success(json!({
"version": env!("CARGO_PKG_VERSION"),
"protocol": types::SCHEMA_VERSION,
"deps_global": { "git": git_ok },
"deps_repo": deps_repo,
}))
}
#[tool(
name = "state",
description = "Cheap repo snapshot: branch, HEAD, dirty, latest run. Use before deciding whether a fresh run_review is needed."
)]
async fn state(&self, Parameters(args): Parameters<StateArgs>) -> CallToolResult {
let root = match read::resolve_repo_root(&args.repo) {
Ok(root) => root,
Err(e) => return e.into_result(),
};
let repo_state = match crate::state::collect_state(
&root,
&crate::state::StateOpts {
fast: true,
json: true,
hot: false,
},
) {
Ok(s) => s,
Err(e) => {
return types::tool_error(
error_class::NOT_A_GIT_REPO,
&format!("failed to read repo state: {e}"),
json!({}),
);
}
};
let repo_name = crate::config::repo_name_from_root(&root);
let branch_key = crate::config::storage_branch_key(&root);
let index = crate::storage::RunIndex::load();
let for_head = read::latest_for_head(&index, &repo_name, &branch_key, &repo_state.head)
.map(run_summary_for_state)
.unwrap_or(serde_json::Value::Null);
let any = read::latest_any(&index, &repo_name, &branch_key)
.map(run_summary_for_state)
.unwrap_or(serde_json::Value::Null);
let dirty = repo_state.is_dirty();
types::tool_success(json!({
"branch": repo_state.branch,
"commit": repo_state.head,
"dirty": dirty,
"files_changed": repo_state.files_changed,
"latest_run_for_head": for_head,
"latest_run_any": any,
}))
}
#[tool(
name = "run_review",
description = "Generate a review pack. profile=quick is synchronous (<60s budget). profile=deep returns immediately with run_id; poll verdict(run_id) for completion."
)]
async fn run_review(&self, Parameters(args): Parameters<RunReviewArgs>) -> CallToolResult {
let root = match read::resolve_repo_root(&args.repo) {
Ok(root) => root,
Err(e) => return e.into_result(),
};
let profile = match run::Profile::parse(args.profile.as_deref()) {
Ok(p) => p,
Err(e) => return e.into_result(),
};
match run::start(&root, args.base, profile).await {
Ok(body) => types::tool_success(body),
Err(e) => e.into_result(),
}
}
#[tool(
name = "verdict",
description = "Single decision truth for a run. Default: latest run for repo HEAD. For deep runs poll this until status=completed."
)]
async fn verdict(&self, Parameters(args): Parameters<VerdictArgs>) -> CallToolResult {
let root = match read::resolve_repo_root(&args.repo) {
Ok(root) => root,
Err(e) => return e.into_result(),
};
let resolved = match read::resolve_run(&root, args.run_id.as_deref()) {
Ok(r) => r,
Err(e) => return e.into_result(),
};
match read::run_status(&resolved.run_dir) {
read::RunStatus::Completed => {
let decision = match read::read_decision(&resolved.run_dir) {
Ok(d) => d,
Err(e) => return e.into_result(),
};
types::tool_success(json!({
"run_id": resolved.run_id,
"commit": resolved.commit,
"status": "completed",
"base_used": decision.base_used,
"merge_recommendation": decision.merge_recommendation,
"allow_merge": decision.allow_merge,
"verdict": decision.verdict,
"blocking_issues": decision.blocking_issues,
"caveats": decision.caveats,
"gates": read::read_gates(&resolved.run_dir),
"generated_at": read::read_generated_at(&resolved.run_dir),
}))
}
read::RunStatus::Running { .. } => {
let marker = read::read_running_marker(&resolved.run_dir);
types::tool_success(json!({
"run_id": resolved.run_id,
"commit": resolved.commit,
"status": "running",
"base_used": marker.map(|m| m.base_used).unwrap_or_default(),
"retry_after_ms": 5000,
}))
}
read::RunStatus::Stale { started_at, .. } => {
let marker = read::read_running_marker(&resolved.run_dir);
types::tool_success(json!({
"run_id": resolved.run_id,
"commit": resolved.commit,
"status": "stale",
"started_at": started_at,
"base_used": marker.map(|m| m.base_used).unwrap_or_default(),
}))
}
read::RunStatus::Failed => types::tool_success(json!({
"run_id": resolved.run_id,
"commit": resolved.commit,
"status": "failed",
"base_used": [],
})),
}
}
#[tool(
name = "findings",
description = "Paged structured findings for a run. Prefer this over read_artifact."
)]
async fn findings(&self, Parameters(args): Parameters<FindingsArgs>) -> CallToolResult {
let root = match read::resolve_repo_root(&args.repo) {
Ok(root) => root,
Err(e) => return e.into_result(),
};
let resolved = match read::resolve_run(&root, args.run_id.as_deref()) {
Ok(r) => r,
Err(e) => return e.into_result(),
};
if let Err(e) = require_completed(&resolved.run_dir) {
return e.into_result();
}
let mut items = read::read_findings(&resolved.run_dir);
if let Some(sev) = &args.severity {
items.retain(|i| i.severity.eq_ignore_ascii_case(sev));
}
if let Some(prefix) = &args.path {
items.retain(|i| i.file.starts_with(prefix));
}
let total = items.len();
let offset = parse_cursor(&args.cursor).min(total);
let limit = args.limit.unwrap_or(100).clamp(1, 1000);
let page: Vec<serde_json::Value> = items
.iter()
.skip(offset)
.take(limit)
.map(|i| {
json!({
"file": i.file,
"line": i.line,
"severity": i.severity,
"rule": i.rule,
"message": i.message,
"artifact_ref": read::sarif_ref(),
})
})
.collect();
let next = offset + page.len();
let next_cursor = if next < total {
Some(next.to_string())
} else {
None
};
types::tool_success(json!({
"items": page,
"total": total,
"next_cursor": next_cursor,
}))
}
#[tool(
name = "read_artifact",
description = "Raw artifact body, paged. Use only when findings/verdict summaries are not enough."
)]
async fn read_artifact(
&self,
Parameters(args): Parameters<ReadArtifactArgs>,
) -> CallToolResult {
let root = match read::resolve_repo_root(&args.repo) {
Ok(root) => root,
Err(e) => return e.into_result(),
};
let resolved = match read::resolve_run(&root, Some(&args.run_id)) {
Ok(r) => r,
Err(e) => return e.into_result(),
};
let is_log = args.artifact == "run.log" || args.artifact == "run.stderr.log";
if !is_log
&& read::run_status(&resolved.run_dir) != read::RunStatus::Completed
&& let Err(e) = require_completed(&resolved.run_dir)
{
return e.into_result();
}
let path = match read::resolve_artifact_path(&resolved.run_dir, &args.artifact) {
Ok(p) => p,
Err(e) => return e.into_result(),
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
return types::tool_error(
error_class::ARTIFACT_MISSING,
"artifact is not readable as UTF-8 text",
json!({ "artifact": args.artifact }),
);
}
};
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let offset = parse_cursor(&args.cursor).min(total_lines);
let limit = args.limit.unwrap_or(200).clamp(1, 5000);
let taken: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
let next = offset + taken.len();
let next_cursor = if next < total_lines {
Some(next.to_string())
} else {
None
};
types::tool_success(json!({
"content": taken.join("\n"),
"total_lines": total_lines,
"next_cursor": next_cursor,
}))
}
}
fn parse_cursor(cursor: &Option<String>) -> usize {
cursor
.as_deref()
.and_then(|c| c.parse::<usize>().ok())
.unwrap_or(0)
}
fn require_completed(run_dir: &std::path::Path) -> Result<(), types::ToolError> {
match read::run_status(run_dir) {
read::RunStatus::Completed => Ok(()),
read::RunStatus::Running { .. } => Err(types::ToolError::with_extra(
error_class::STALE_RUN,
"run is still in progress; poll verdict until completed",
json!({ "retry_after_ms": 5000 }),
)),
read::RunStatus::Stale { .. } => Err(types::ToolError::new(
error_class::STALE_RUN,
"run is stale (its process died before completing)",
)),
read::RunStatus::Failed => Err(types::ToolError::new(
error_class::RUN_FAILED,
"run failed and produced no completed pack",
)),
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for PrviewMcp {
fn get_info(&self) -> rmcp::model::ServerInfo {
use rmcp::model::{Implementation, InitializeResult, ServerCapabilities};
let mut server_info = Implementation::from_build_env();
server_info.name = "prview".to_string();
server_info.version = env!("CARGO_PKG_VERSION").to_string();
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = server_info;
info.instructions = Some(
"prview review server. Call health at session start, run_review to generate a \
review pack, then verdict/findings/read_artifact to consume it. Every tool takes \
an absolute `repo` path; responses carry schema_version prview.mcp.v1."
.to_string(),
);
info
}
}
fn profile_tool_availability(kind: crate::config::ProfileKind) -> serde_json::Value {
use crate::config::ProfileKind;
const RUST: &[&str] = &["cargo", "cargo-clippy", "rustfmt"];
const JS: &[&str] = &["node", "npm"];
const PYTHON: &[&str] = &["python3", "ruff", "mypy"];
let mut tools: Vec<&str> = Vec::new();
match kind {
ProfileKind::Rust => tools.extend_from_slice(RUST),
ProfileKind::Js => tools.extend_from_slice(JS),
ProfileKind::Python => tools.extend_from_slice(PYTHON),
ProfileKind::Mixed => {
tools.extend_from_slice(RUST);
tools.extend_from_slice(JS);
tools.extend_from_slice(PYTHON);
}
ProfileKind::Generic => {}
}
let map: serde_json::Map<String, serde_json::Value> = tools
.into_iter()
.map(|bin| (bin.to_string(), json!(which::which(bin).is_ok())))
.collect();
serde_json::Value::Object(map)
}
fn run_summary_for_state(entry: &crate::storage::RunEntry) -> serde_json::Value {
json!({
"run_id": entry.id,
"commit": entry.commit,
"status": "completed",
"profile": serde_json::Value::Null,
"base_used": read::read_bases(&entry.path),
"merge_status": entry.merge_status,
"generated_at": entry.created_at,
})
}
pub async fn serve() -> anyhow::Result<()> {
let service = PrviewMcp::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}