Skip to main content
Glama
sin5ddd
by sin5ddd
main.rs19.3 kB
mod analyze; mod filters; mod model; mod utils; use anyhow::Result; use rmcp::{ handler::server::ServerHandler, model::{ CallToolRequestParam, CallToolResult, ListToolsResult, PaginatedRequestParam, Tool, InitializeRequestParam, InitializeResult, Content, CompleteRequestParam, CompleteResult, SetLevelRequestParam, GetPromptRequestParam, GetPromptResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, ReadResourceRequestParam, ReadResourceResult, SubscribeRequestParam, UnsubscribeRequestParam, CancelledNotificationParam, ProgressNotificationParam, ServerCapabilities, Implementation, ProtocolVersion, ErrorData as McpError, CompletionInfo, }, service::{RoleServer, ServiceExt, RequestContext, NotificationContext}, }; use serde_json::Value; use std::sync::Arc; use crate::analyze::analyze_basic; use crate::model::{Analyzed, AnalyzerCache}; use crate::utils::{ guess_ext_is_midi, resolve_bytes_and_id, sha256_hex, }; struct MidiAnalyzerService { cache: Arc<tokio::sync::Mutex<AnalyzerCache>>, } impl Default for MidiAnalyzerService { fn default() -> Self { Self { cache: Arc::new(tokio::sync::Mutex::new(AnalyzerCache::default())), } } } impl Clone for MidiAnalyzerService { fn clone(&self) -> Self { Self { cache: Arc::clone(&self.cache), } } } impl MidiAnalyzerService { async fn midi_open_file_handler(&self, args: &Value) -> Result<Value, McpError> { let path = args .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| McpError::invalid_request("missing path", None))?; let alias = args.get("alias").and_then(|v| v.as_str()); let validate_ext = args .get("validate_extension") .and_then(|v| v.as_bool()) .unwrap_or(true); let max_size = args .get("max_size_bytes") .and_then(|v| v.as_u64()) .unwrap_or(10 * 1024 * 1024); if validate_ext && !guess_ext_is_midi(path) { return Err(McpError::invalid_request("unsupported extension (expected .mid/.midi)", None)); } let meta = tokio::fs::metadata(path) .await .map_err(|e| McpError::invalid_request(format!("stat failed: {}: {}", path, e), None))?; if !meta.is_file() { return Err(McpError::invalid_request("not a file", None)); } if meta.len() as u64 > max_size { return Err(McpError::invalid_request(format!("file too large: {} bytes", meta.len()), None)); } let bytes = tokio::fs::read(path) .await .map_err(|e| McpError::invalid_request(format!("read failed: {}: {}", path, e), None))?; let file_id = sha256_hex(&bytes); let mut cache = self.cache.lock().await; if !cache.by_id.contains_key(&file_id) { let smf = midly::Smf::parse(&bytes) .map_err(|e| McpError::invalid_request(format!("midly parse error: {}", e), None))?; let (format, ppq) = match smf.header.timing { midly::Timing::Metrical(p) => (format!("{:?}", smf.header.format), p.as_int()), midly::Timing::Timecode(_, _) => (format!("{:?}", smf.header.format), 480), }; let duration_ticks: u64 = smf .tracks .iter() .map(|t| t.iter().map(|e| e.delta.as_int() as u64).sum::<u64>()) .max() .unwrap_or(0); let analyzed = Analyzed { file_id: file_id.clone(), format, ppq, duration_ticks, duration_sec: None, tempo_map: Vec::new(), time_sigs: Vec::new(), key_sigs: Vec::new(), tracks: Vec::new(), }; cache.by_id.insert(file_id.clone(), analyzed); } if let Some(a) = alias { cache.by_alias.insert(a.into(), file_id.clone()); } Ok(serde_json::json!({ "ok": true, "file_id": file_id, "path": path, "size_bytes": bytes.len(), "alias": alias })) } async fn midi_file_info_handler(&self, args: &Value) -> Result<Value, McpError> { let mut cache = self.cache.lock().await; let (bytes, fid) = resolve_bytes_and_id(args, &mut *cache) .await .map_err(|e| McpError::invalid_request(e.to_string(), None))?; drop(cache); let mut cache = self.cache.lock().await; let analyzed = if let Some(a) = cache.by_id.get(&fid) { a.clone() } else { if bytes.is_empty() { return Err(McpError::invalid_request( "file not loaded; provide path or call midi_open_file first", None )); } let a = analyze_basic(&bytes) .map_err(|e| McpError::invalid_request(e.to_string(), None))?; cache.by_id.insert(a.file_id.clone(), a.clone()); a }; let include_tempo_map = args .get("include_tempo_map") .and_then(|v| v.as_bool()) .unwrap_or(true); let include_time_sigs = args .get("include_time_signatures") .and_then(|v| v.as_bool()) .unwrap_or(true); let include_key_sigs = args .get("include_key_signatures") .and_then(|v| v.as_bool()) .unwrap_or(true); Ok(serde_json::json!({ "ok": true, "file_id": analyzed.file_id, "format": analyzed.format, "ppq": analyzed.ppq, "duration_ticks": analyzed.duration_ticks, "duration_sec": analyzed.duration_sec, "tempo_map": if include_tempo_map { analyzed.tempo_map } else { Vec::<(u64,f64)>::new() }, "time_signatures": if include_time_sigs { analyzed.time_sigs } else { Vec::<(u64,u8,u8)>::new() }, "key_signatures": if include_key_sigs { analyzed.key_sigs } else { Vec::<(u64,i8,bool)>::new() }, "tracks": analyzed.tracks })) } async fn midi_track_list_handler(&self, args: &Value) -> Result<Value, McpError> { let mut cache = self.cache.lock().await; let (bytes, fid) = resolve_bytes_and_id(args, &mut *cache) .await .map_err(|e| McpError::invalid_request(e.to_string(), None))?; drop(cache); let mut cache = self.cache.lock().await; let analyzed = if let Some(a) = cache.by_id.get(&fid) { a.clone() } else { if bytes.is_empty() { return Err(McpError::invalid_request( "file not loaded; provide path or midi_open_file first", None )); } let a = analyze_basic(&bytes) .map_err(|e| McpError::invalid_request(e.to_string(), None))?; cache.by_id.insert(a.file_id.clone(), a.clone()); a }; let track_indexes: Option<Vec<usize>> = args .get("track_indexes") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|x| x.as_u64().map(|u| u as usize)) .collect() }); let channel_filter: Option<Vec<u8>> = args .get("channel_filter") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|x| x.as_u64().map(|u| u as u8)) .collect() }); let program_filter: Option<Vec<u8>> = args .get("program_filter") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|x| x.as_u64().map(|u| u as u8)) .collect() }); let mut tracks = analyzed.tracks; if let Some(idxs) = track_indexes { tracks.retain(|t| idxs.contains(&t.index)); } if let Some(chs) = channel_filter { tracks.retain(|t| t.channels.iter().any(|c| chs.contains(c))); } if let Some(ps) = program_filter { tracks.retain(|t| t.programs.values().any(|p| ps.contains(p))); } Ok(serde_json::json!({"ok": true, "file_id": analyzed.file_id, "tracks": tracks})) } } #[derive(Clone)] struct MidiService { inner: Arc<MidiAnalyzerService>, } impl MidiService { fn new() -> Self { Self { inner: Arc::new(MidiAnalyzerService::default()), } } } impl ServerHandler for MidiService { fn ping( &self, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<(), McpError>> + Send + '_ { async { Ok(()) } } fn initialize( &self, _request: InitializeRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<InitializeResult, McpError>> + Send + '_ { async { Ok(InitializeResult { protocol_version: ProtocolVersion::V_2024_11_05, server_info: Implementation { name: "midi-analyzer-mcp".into(), version: env!("CARGO_PKG_VERSION").into(), }, capabilities: ServerCapabilities { tools: Some(Default::default()), ..Default::default() }, instructions: None, }) } } fn complete( &self, _request: CompleteRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<CompleteResult, McpError>> + Send + '_ { async { Ok(CompleteResult { completion: CompletionInfo { values: vec![], has_more: Some(false), total: Some(0) } }) } } fn set_level( &self, _request: SetLevelRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<(), McpError>> + Send + '_ { async { Ok(()) } } fn get_prompt( &self, _request: GetPromptRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<GetPromptResult, McpError>> + Send + '_ { async { Err(McpError::invalid_request("get_prompt not supported", None)) } } fn list_prompts( &self, _request: Option<PaginatedRequestParam>, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<ListPromptsResult, McpError>> + Send + '_ { async { Ok(ListPromptsResult { prompts: vec![], next_cursor: None }) } } fn list_resources( &self, _request: Option<PaginatedRequestParam>, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ { async { Ok(ListResourcesResult { resources: vec![], next_cursor: None }) } } fn list_resource_templates( &self, _request: Option<PaginatedRequestParam>, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<ListResourceTemplatesResult, McpError>> + Send + '_ { async { Ok(ListResourceTemplatesResult { resource_templates: vec![], next_cursor: None }) } } fn read_resource( &self, _request: ReadResourceRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<ReadResourceResult, McpError>> + Send + '_ { async { Err(McpError::invalid_request("read_resource not supported", None)) } } fn subscribe( &self, _request: SubscribeRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<(), McpError>> + Send + '_ { async { Ok(()) } } fn unsubscribe( &self, _request: UnsubscribeRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<(), McpError>> + Send + '_ { async { Ok(()) } } fn call_tool( &self, request: CallToolRequestParam, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ { let inner = Arc::clone(&self.inner); async move { let args = request.arguments.as_ref() .map(|v| serde_json::to_value(v).unwrap_or(Value::Null)) .unwrap_or(Value::Null); let result = match request.name.as_ref() { "midi_open_file" => inner.midi_open_file_handler(&args).await, "midi_file_info" => inner.midi_file_info_handler(&args).await, "midi_track_list" => inner.midi_track_list_handler(&args).await, _ => Err(McpError::invalid_request(format!("unknown tool: {}", request.name), None)), }; match result { Ok(val) => Ok(CallToolResult { content: Some(vec![Content::text(serde_json::to_string_pretty(&val).unwrap_or_default())]), structured_content: None, is_error: Some(false), }), Err(e) => Ok(CallToolResult { content: Some(vec![Content::text(format!("Error: {:?}", e))]), structured_content: None, is_error: Some(true), }), } } } fn list_tools( &self, _request: Option<PaginatedRequestParam>, _context: RequestContext<RoleServer>, ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ { async { Ok(ListToolsResult { tools: vec![ Tool { name: "midi_open_file".into(), description: Some("指定パスのMIDIを読み込み、file_idを返す(後続ツールはfile_idを使用可能)".into()), input_schema: std::sync::Arc::new(serde_json::from_value::<serde_json::Map<String, Value>>( serde_json::json!({ "type": "object", "properties": { "path": {"type": "string"}, "alias": {"type": "string"}, "validate_extension": {"type": "boolean", "default": true}, "max_size_bytes": {"type": "integer", "default": 10485760} }, "required": ["path"] })).unwrap()), annotations: None, output_schema: None, }, Tool { name: "midi_file_info".into(), description: Some("ファイル全体概要(メタ/テンポ/拍子/キー/曲尺/トラック統計など)。file_idまたはpathのいずれかが必須".into()), input_schema: std::sync::Arc::new(serde_json::from_value::<serde_json::Map<String, Value>>( serde_json::json!({ "type": "object", "properties": { "file_id": {"type": "string", "description": "midi_open_fileで取得したID"}, "path": {"type": "string", "description": "MIDIファイルのパス"}, "include_tempo_map": {"type": "boolean", "default": true}, "include_time_signatures": {"type": "boolean", "default": true}, "include_key_signatures": {"type": "boolean", "default": true} } })).unwrap()), annotations: None, output_schema: None, }, Tool { name: "midi_track_list".into(), description: Some("トラック一覧(名前/チャンネル/プログラム/ノート統計)。file_idまたはpathのいずれかが必須".into()), input_schema: std::sync::Arc::new(serde_json::from_value::<serde_json::Map<String, Value>>( serde_json::json!({ "type": "object", "properties": { "file_id": {"type": "string", "description": "midi_open_fileで取得したID"}, "path": {"type": "string", "description": "MIDIファイルのパス"}, "track_indexes": {"type": "array", "items": {"type": "integer"}}, "channel_filter": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 15}}, "program_filter": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 127}} } })).unwrap()), annotations: None, output_schema: None, }, ], next_cursor: None, }) } } fn on_cancelled( &self, _notification: CancelledNotificationParam, _context: NotificationContext<RoleServer>, ) -> impl std::future::Future<Output = ()> + Send + '_ { async {} } fn on_progress( &self, _notification: ProgressNotificationParam, _context: NotificationContext<RoleServer>, ) -> impl std::future::Future<Output = ()> + Send + '_ { async {} } fn on_initialized( &self, _context: NotificationContext<RoleServer>, ) -> impl std::future::Future<Output = ()> + Send + '_ { async {} } fn on_roots_list_changed( &self, _context: NotificationContext<RoleServer>, ) -> impl std::future::Future<Output = ()> + Send + '_ { async {} } fn get_info(&self) -> rmcp::model::ServerInfo { rmcp::model::ServerInfo { protocol_version: ProtocolVersion::V_2024_11_05, server_info: Implementation { name: "midi-analyzer-mcp".into(), version: env!("CARGO_PKG_VERSION").into(), }, capabilities: ServerCapabilities { tools: Some(Default::default()), ..Default::default() }, instructions: None, } } } #[tokio::main] async fn main() -> Result<()> { let service = MidiService::new(); // Serve using stdio transport let server = service.serve((tokio::io::stdin(), tokio::io::stdout())).await .map_err(|e| anyhow::anyhow!("Failed to start server: {:?}", e))?; // Wait for the server to complete server.waiting().await .map_err(|e| anyhow::anyhow!("Server error: {:?}", e))?; Ok(()) }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sin5ddd/midi-analyer-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server