use std::io::Write as _;
use std::sync::Arc;
use serde_json::Value;
use tempfile::NamedTempFile;
use tracing::info;
use trusty_common::console_metrics::CONSOLE_METRICS_METHOD;
use crate::{
integrations::github::{AuthStrategy, GithubClient, RunMode},
mcp::console_metrics,
models::ReviewResult,
pipeline::{DiffSource, ReviewDeps, ReviewInput, TriggerDecision, run_review},
service::{
AppState,
handlers::{DepInfo, DepStatus, compute_status},
},
};
pub fn tool_descriptors() -> Value {
let mut tools = serde_json::json!([
{
"name": "review_pr",
"description": "Review a GitHub pull request. Fetches the PR diff, retrieves \
code context from trusty-search, and returns a structured verdict \
(APPROVE / APPROVE* / REQUEST_CHANGES / BLOCK / UNKNOWN) with \
actionable findings. Requires GITHUB_TOKEN and AWS Bedrock \
credentials (or OPENROUTER_API_KEY for OpenRouter provider). \
Dry-run by default (PR_INTELLIGENCE_DRY_RUN=true — no GitHub \
comments posted). trusty-search must be running on :7878.",
"inputSchema": {
"type": "object",
"required": ["owner", "repo", "pr"],
"properties": {
"owner": {
"type": "string",
"description": "GitHub organisation or user that owns the repository"
},
"repo": {
"type": "string",
"description": "GitHub repository name"
},
"pr": {
"type": "integer",
"description": "Pull request number"
},
"reviewer_model": {
"type": "string",
"description": "Override the reviewer model slug. \
Use a `bedrock/<id>` prefix to force AWS Bedrock, \
`openrouter/<id>` for OpenRouter. \
Default: us.anthropic.claude-sonnet-4-6 on Bedrock.",
"examples": [
"bedrock/us.anthropic.claude-sonnet-4-6",
"bedrock/us.anthropic.claude-haiku-4-5",
"openrouter/openai/gpt-5.4-mini-20260317"
]
}
}
}
},
{
"name": "review_diff",
"description": "Review a raw unified diff string without fetching from GitHub. \
Useful for reviewing local changes, staged diffs, or patches. \
No GitHub credentials required. \
Requires AWS Bedrock credentials (or OPENROUTER_API_KEY). \
trusty-search on :7878 is used for code-context retrieval when available.",
"inputSchema": {
"type": "object",
"required": ["diff"],
"properties": {
"diff": {
"type": "string",
"description": "Unified diff string (output of `git diff` or similar)"
},
"context": {
"type": "string",
"description": "Optional human-readable context — e.g. PR title/description, \
ticket number, or a note about what changed and why. \
Appended to the diff file so the reviewer model sees it."
},
"reviewer_model": {
"type": "string",
"description": "Override the reviewer model slug (same format as review_pr)."
}
}
}
},
{
"name": "review_health",
"description": "Probe trusty-review service liveness and configuration. \
Returns the current configuration (dry_run mode, reviewer model) \
and dependency reachability. Safe to call without any credentials.",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]);
if let Some(arr) = tools.as_array_mut() {
arr.push(console_metrics::descriptor());
}
tools
}
#[derive(Debug)]
pub enum ToolError {
UnknownTool,
InvalidParams(String),
}
pub async fn call_tool(tool: &str, args: &Value, state: &AppState) -> Result<Value, ToolError> {
match tool {
"review_pr" => call_review_pr(args, state).await,
"review_diff" => call_review_diff(args, state).await,
"review_health" => Ok(call_review_health(state).await),
name if name == CONSOLE_METRICS_METHOD => Ok(wrap_value(
&console_metrics::handle_console_metrics(state).await,
)),
_ => Err(ToolError::UnknownTool),
}
}
async fn call_review_pr(args: &Value, state: &AppState) -> Result<Value, ToolError> {
let owner = require_str(args, "owner")?;
let repo = require_str(args, "repo")?;
let pr = args
.get("pr")
.and_then(Value::as_u64)
.ok_or_else(|| ToolError::InvalidParams("missing or non-integer 'pr'".into()))?;
let reviewer_model = args
.get("reviewer_model")
.and_then(Value::as_str)
.unwrap_or(&state.config.role_models.reviewer.model)
.to_string();
let client = GithubClient::new()
.map_err(|e| ToolError::InvalidParams(format!("failed to build HTTP client: {e}")))?;
let token = AuthStrategy::select(RunMode::Serve, None)
.resolve_token(&client, &state.config, owner)
.await
.map_err(|e| ToolError::InvalidParams(format!("GitHub auth failed: {e}")))?;
let diff_source = DiffSource::Github {
owner: owner.to_string(),
repo: repo.to_string(),
pr,
token,
};
let (deps, reviewer_model_fallback) = deps_from_state(state, &reviewer_model).await;
let input = ReviewInput {
diff_source,
reviewer_model: reviewer_model.clone(),
write_log: false,
print_result: false,
trigger: TriggerDecision::ForceDryRun,
run_mode: RunMode::Serve,
allow_posting: false,
};
info!(owner, repo, pr, reviewer_model, "mcp: review_pr");
let result = run_review(&state.config, input, deps).await;
Ok(wrap_result(&result, reviewer_model_fallback.as_deref()))
}
async fn call_review_diff(args: &Value, state: &AppState) -> Result<Value, ToolError> {
let diff = require_str(args, "diff")?;
let context = args.get("context").and_then(Value::as_str).unwrap_or("");
let reviewer_model = args
.get("reviewer_model")
.and_then(Value::as_str)
.unwrap_or(&state.config.role_models.reviewer.model)
.to_string();
let mut tmp = NamedTempFile::new()
.map_err(|e| ToolError::InvalidParams(format!("failed to create temp file: {e}")))?;
if !context.is_empty() {
writeln!(tmp, "# Context: {context}")
.map_err(|e| ToolError::InvalidParams(format!("temp file write error: {e}")))?;
}
tmp.write_all(diff.as_bytes())
.map_err(|e| ToolError::InvalidParams(format!("temp file write error: {e}")))?;
tmp.flush()
.map_err(|e| ToolError::InvalidParams(format!("temp file flush error: {e}")))?;
let path = tmp.path().to_path_buf();
let diff_source = DiffSource::LocalFile { path };
let (deps, reviewer_model_fallback) = deps_from_state(state, &reviewer_model).await;
let input = ReviewInput {
diff_source,
reviewer_model: reviewer_model.clone(),
write_log: false,
print_result: false,
trigger: TriggerDecision::ForceDryRun,
run_mode: RunMode::Serve,
allow_posting: false,
};
info!(bytes = diff.len(), reviewer_model, "mcp: review_diff");
let result = run_review(&state.config, input, deps).await;
Ok(wrap_result(&result, reviewer_model_fallback.as_deref()))
}
async fn call_review_health(state: &AppState) -> Value {
let reviewer_model = state.config.role_models.reviewer.model.clone();
let search_reachable = state.search.health().await.is_ok_and(|r| r.is_healthy());
let analyze_reachable = match &state.analyze {
Some(a) => a.health().await.is_ok(),
None => false,
};
let inference = state
.inference_probe
.probe(&state.llm, &reviewer_model)
.await;
let deps = DepStatus {
trusty_search: DepInfo {
required: true,
reachable: search_reachable,
},
trusty_analyze: DepInfo {
required: false,
reachable: analyze_reachable,
},
};
let status = compute_status(inference, &deps);
let result = serde_json::json!({
"status": status,
"version": env!("CARGO_PKG_VERSION"),
"dry_run": state.config.dry_run,
"reviewer_model": reviewer_model,
"inference": inference,
"deps": {
"trusty_search": {
"required": deps.trusty_search.required,
"reachable": deps.trusty_search.reachable,
},
"trusty_analyze": {
"required": deps.trusty_analyze.required,
"reachable": deps.trusty_analyze.reachable,
},
},
});
wrap_value(&result)
}
async fn deps_from_state(state: &AppState, reviewer_model: &str) -> (ReviewDeps, Option<String>) {
let startup_provider = &state.config.role_models.reviewer.provider;
let (override_provider, _bare) =
crate::llm::resolve_provider_and_model(reviewer_model, startup_provider);
let mut fallback_reason: Option<String> = None;
let llm = if &override_provider == startup_provider {
Arc::clone(&state.llm)
} else {
match crate::llm::build_provider(
reviewer_model,
startup_provider,
&state.config.openrouter_api_key,
)
.await
{
Ok(p) => p,
Err(e) => {
let reason = format!(
"failed to build provider for reviewer_model override '{reviewer_model}' \
({e}); fell back to the startup '{startup_provider}' provider"
);
tracing::warn!(
reviewer_model,
error = %e,
"mcp: failed to build provider for reviewer_model override — \
falling back to startup provider"
);
fallback_reason = Some(reason);
Arc::clone(&state.llm)
}
}
};
let deps = ReviewDeps {
llm,
verifier: state.verifier.clone(),
search: Arc::clone(&state.search),
analyze: state.analyze.clone(),
dedup: state.dedup.clone(),
};
(deps, fallback_reason)
}
fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, ToolError> {
args.get(key)
.and_then(Value::as_str)
.ok_or_else(|| ToolError::InvalidParams(format!("missing or non-string '{key}'")))
}
fn wrap_result(result: &ReviewResult, fallback: Option<&str>) -> Value {
let mut payload = serde_json::to_value(result).unwrap_or(Value::Null);
if let (Some(reason), Some(obj)) = (fallback, payload.as_object_mut()) {
obj.insert(
"reviewer_model_fallback".to_string(),
Value::String(reason.to_string()),
);
}
let text = serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| serde_json::to_string(&payload).unwrap_or_default());
let mut envelope = serde_json::json!({
"content": [{ "type": "text", "text": text }],
"isError": false,
});
if let (Some(reason), Some(obj)) = (fallback, envelope.as_object_mut()) {
obj.insert(
"reviewer_model_fallback".to_string(),
Value::String(reason.to_string()),
);
}
envelope
}
fn wrap_value(value: &Value) -> Value {
let text = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
serde_json::json!({
"content": [{ "type": "text", "text": text }],
"isError": false,
})
}
pub fn wrap_tool_error(msg: &str) -> Value {
serde_json::json!({
"content": [{ "type": "text", "text": format!("Error: {msg}") }],
"isError": true,
})
}
#[cfg(test)]
#[path = "tools_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "tools_dispatch_tests.rs"]
mod dispatch_tests;