use anyhow::Result;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{ErrorData, ServerCapabilities, ServerInfo};
use rmcp::{ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
use polyvoice::cli_common;
use polyvoice::models::ModelRegistry;
use polyvoice::pipeline_v2::PipelineConfig;
use polyvoice::types::{DEFAULT_AHC_THRESHOLD, DiarizationResult, Profile, SampleRate};
use polyvoice::wav::read_wav;
const ERR_INVALID_ARG: i32 = 1;
const ERR_MODEL_LOAD: i32 = 10;
const ERR_INFERENCE: i32 = 11;
const ERR_REGISTRY: i32 = 30;
const ERR_INTERNAL: i32 = 99;
fn err(code: i32, message: impl Into<String>) -> ErrorData {
let message = message.into();
let data = Some(serde_json::json!({ "code": code, "message": message }));
if code == ERR_INVALID_ARG {
ErrorData::invalid_params(message, data)
} else {
ErrorData::internal_error(message, data)
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DiarizeInput {
path: String,
#[serde(default)]
profile: Option<String>,
#[serde(default)]
clusterer: Option<String>,
#[serde(default)]
threshold: Option<f32>,
#[serde(default)]
max_speakers: Option<usize>,
#[serde(default)]
vbx_plda_dir: Option<String>,
#[serde(default)]
verbosity: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)] struct TranscribeInput {
path: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SpeakerRollup {
label: String,
id: u32,
total_speech_s: f64,
turn_count: usize,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TurnDto {
speaker: String,
speaker_id: u32,
start: f64,
end: f64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct DiarizeOutput {
schema_version: String,
num_speakers: usize,
duration_s: f64,
speakers: Vec<SpeakerRollup>,
#[serde(skip_serializing_if = "Option::is_none")]
turns: Option<Vec<TurnDto>>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct Capabilities {
name: String,
version: String,
tools: Vec<String>,
asr_available: bool,
output_formats: Vec<String>,
profiles: Vec<String>,
}
#[derive(Clone)]
struct PolyvoiceMcp;
#[tool_router]
impl PolyvoiceMcp {
fn new() -> Self {
Self
}
#[tool(
name = "polyvoice.capabilities",
description = "List the tools, version, ASR availability, and output formats of this server."
)]
fn capabilities(&self) -> Json<Capabilities> {
Json(Capabilities {
name: "polyvoice-mcp".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
tools: vec![
"polyvoice.diarize".to_owned(),
"polyvoice.transcribe".to_owned(),
"polyvoice.diarize_and_transcribe".to_owned(),
"polyvoice.capabilities".to_owned(),
],
asr_available: false,
output_formats: vec![
"rttm".to_owned(),
"json".to_owned(),
"srt".to_owned(),
"vtt".to_owned(),
"txt".to_owned(),
],
profiles: vec!["balanced".to_owned(), "mobile".to_owned()],
})
}
#[tool(
name = "polyvoice.diarize",
description = "Diarize a WAV file (who spoke when). Returns the canonical DiarizationResult v1 (concise rollup, or full turns with verbosity=detailed)."
)]
fn diarize(
&self,
Parameters(input): Parameters<DiarizeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
let result = run_diarize(&input)?;
let detailed = input.verbosity.as_deref() == Some("detailed");
Ok(Json(project(&result, detailed)))
}
#[tool(
name = "polyvoice.transcribe",
description = "Transcribe a WAV file. Requires the optional polyvoice-asr crate, which is not installed."
)]
fn transcribe(
&self,
Parameters(_input): Parameters<TranscribeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
Err(asr_unavailable())
}
#[tool(
name = "polyvoice.diarize_and_transcribe",
description = "Diarize + transcribe (who said what). Requires the optional polyvoice-asr crate, which is not installed."
)]
fn diarize_and_transcribe(
&self,
Parameters(_input): Parameters<DiarizeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
Err(asr_unavailable())
}
}
#[tool_handler]
impl ServerHandler for PolyvoiceMcp {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.instructions = Some(
"polyvoice speaker diarization (pipeline v2 + VBx by default, same as the CLI). \
Call polyvoice.diarize with a WAV path to get who-spoke-when; \
polyvoice.capabilities to discover features. Pass clusterer=ahc for fixed-threshold \
AHC. Transcription tools require the optional polyvoice-asr crate."
.to_owned(),
);
info
}
}
fn asr_unavailable() -> ErrorData {
err(
ERR_INTERNAL,
"ASR is unavailable: install the optional `polyvoice-asr` companion crate to enable transcription",
)
}
fn project(result: &DiarizationResult, detailed: bool) -> DiarizeOutput {
let speakers = result
.speakers
.iter()
.map(|s| SpeakerRollup {
label: s.label.clone(),
id: s.id,
total_speech_s: s.total_speech_s,
turn_count: s.turn_count,
})
.collect();
let turns = detailed.then(|| {
result
.turns
.iter()
.map(|t| TurnDto {
speaker: t.speaker.to_string(),
speaker_id: t.speaker.0,
start: t.time.start,
end: t.time.end,
})
.collect()
});
DiarizeOutput {
schema_version: result.schema_version.clone(),
num_speakers: result.num_speakers,
duration_s: result.audio.duration_secs,
speakers,
turns,
}
}
fn resolve_max_speakers(max_speakers: Option<usize>) -> Result<u8, ErrorData> {
match max_speakers {
None => Ok(PipelineConfig::default().max_speakers),
Some(n) => cli_common::max_speakers_u8(n).map_err(|e| err(ERR_INVALID_ARG, e.to_string())),
}
}
fn run_diarize(input: &DiarizeInput) -> Result<DiarizationResult, ErrorData> {
let path = Path::new(&input.path);
if !path.is_file() {
return Err(err(
ERR_INVALID_ARG,
format!("no such file: {}", input.path),
));
}
let profile: Profile = input
.profile
.as_deref()
.unwrap_or("balanced")
.parse()
.map_err(|e: polyvoice::types::ProfileParseError| err(ERR_INVALID_ARG, e.to_string()))?;
let clusterer_kind = cli_common::parse_clusterer_kind(
input.clusterer.as_deref().unwrap_or("vbx"),
input.threshold.unwrap_or(DEFAULT_AHC_THRESHOLD),
)
.map_err(|e| err(ERR_INVALID_ARG, e.to_string()))?;
let max_speakers = resolve_max_speakers(input.max_speakers)?;
let registry = ModelRegistry::default().map_err(|e| err(ERR_REGISTRY, e.to_string()))?;
let _models = registry
.ensure_for_profile(profile)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let config = PipelineConfig {
profile,
clusterer: clusterer_kind,
max_speakers,
vbx_plda_dir: input
.vbx_plda_dir
.as_ref()
.map(|s| Path::new(s).to_path_buf()),
..PipelineConfig::default()
};
let pipeline = cli_common::build_v2_pipeline(config, registry)
.map_err(|e| err(ERR_MODEL_LOAD, format!("{e:#}")))?;
let (samples, sr_hz) = read_wav(path).map_err(|e| err(ERR_INVALID_ARG, e.to_string()))?;
let sr = SampleRate::new(sr_hz)
.ok_or_else(|| err(ERR_INVALID_ARG, format!("invalid sample rate {sr_hz} Hz")))?;
pipeline
.run(&samples, sr)
.map_err(|e| err(ERR_INFERENCE, e.to_string()))
}
#[tokio::main]
async fn main() -> Result<()> {
let service = PolyvoiceMcp::new()
.serve(rmcp::transport::io::stdio())
.await?;
service.waiting().await?;
Ok(())
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_lists_four_tools_and_no_asr() {
let cap = PolyvoiceMcp::new().capabilities().0;
assert_eq!(cap.tools.len(), 4);
assert!(!cap.asr_available);
assert!(cap.tools.iter().any(|t| t == "polyvoice.diarize"));
assert_eq!(cap.output_formats.len(), 5);
}
#[test]
fn asr_unavailable_error_carries_ffi_code() {
let e = asr_unavailable();
let data = e.data.expect("data");
assert_eq!(data["code"], ERR_INTERNAL);
assert!(data["message"].as_str().unwrap().contains("polyvoice-asr"));
}
#[test]
fn invalid_arg_maps_to_jsonrpc_invalid_params() {
let e = err(ERR_INVALID_ARG, "no such file: x.wav");
assert_eq!(e.code.0, -32602);
let data = e.data.expect("data");
assert_eq!(data["code"], ERR_INVALID_ARG);
assert_eq!(data["message"], "no such file: x.wav");
}
#[test]
fn model_load_maps_to_jsonrpc_internal_error() {
let e = err(ERR_MODEL_LOAD, "model missing");
assert_eq!(e.code.0, -32603);
let data = e.data.expect("data");
assert_eq!(data["code"], ERR_MODEL_LOAD);
assert_eq!(data["message"], "model missing");
}
#[test]
fn input_schema_is_strict() {
let schema = schemars::schema_for!(DiarizeInput);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["additionalProperties"], serde_json::json!(false));
assert!(json["properties"]["path"].is_object());
}
#[test]
fn max_speakers_accepts_default_and_valid_range() {
assert_eq!(
resolve_max_speakers(None).unwrap(),
PipelineConfig::default().max_speakers
);
assert_eq!(resolve_max_speakers(Some(1)).unwrap(), 1);
assert_eq!(resolve_max_speakers(Some(255)).unwrap(), 255);
}
#[test]
fn max_speakers_rejects_out_of_range_with_invalid_arg() {
for n in [0_usize, 256, 1000] {
let e = resolve_max_speakers(Some(n)).expect_err("out of range must error");
assert_eq!(e.code.0, -32602, "n={n} must map to invalid params");
let data = e.data.expect("data");
assert_eq!(data["code"], ERR_INVALID_ARG);
assert!(
data["message"]
.as_str()
.unwrap()
.contains("max_speakers must be in 1..=255"),
"message must name the valid range: {data}"
);
}
}
}