1use std::path::PathBuf;
6use std::sync::Arc;
7
8use crossbeam_channel::Sender;
9use koan_core::player::commands::PlayerCommand;
10use koan_core::player::state::SharedPlayerState;
11use rmcp::handler::server::router::tool::ToolRouter;
12use rmcp::handler::server::wrapper::Json;
13use rmcp::model::{ServerCapabilities, ServerInfo};
14use rmcp::{ServerHandler, schemars, tool_router};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Deserialize, schemars::JsonSchema)]
22pub struct GraphqlParams {
23 #[schemars(
24 description = "GraphQL query or mutation string. Use the schema_sdl tool first to learn available types, queries, mutations, and filter parameters."
25 )]
26 pub query: String,
27 #[schemars(description = "Optional JSON object of query variables")]
28 pub variables: Option<serde_json::Value>,
29}
30
31#[derive(Debug, Serialize, schemars::JsonSchema)]
37pub struct GraphqlResponse {
38 pub result: serde_json::Value,
40}
41
42#[derive(Clone)]
47pub struct KoanMcpServer {
48 #[allow(dead_code)]
49 tool_router: ToolRouter<Self>,
50 graphql_schema: crate::graphql::KoanSchema,
51}
52
53impl KoanMcpServer {
54 pub fn new(
55 state: Arc<SharedPlayerState>,
56 cmd_tx: Sender<PlayerCommand>,
57 db_path: PathBuf,
58 ) -> Self {
59 let graphql_schema =
60 crate::graphql::build_schema(state.clone(), cmd_tx.clone(), db_path.clone(), None);
61 Self {
62 tool_router: Self::tool_router(),
63 graphql_schema,
64 }
65 }
66}
67
68use rmcp::handler::server::wrapper::Parameters;
69use rmcp::tool;
70
71fn mcp_role() -> koan_core::auth::Role {
79 if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
80 koan_core::auth::Role::Admin
81 } else {
82 koan_core::auth::Role::User
83 }
84}
85
86#[tool_router]
87impl KoanMcpServer {
88 #[tool(
89 description = "Get the full GraphQL schema in SDL format. CALL THIS FIRST to learn all \
90 available queries, mutations, types, and filter parameters. The schema is the complete \
91 reference for everything koan can do — library discovery, playback control, queue \
92 management, favourites, snapshots, radio mode, device switching, and more."
93 )]
94 fn schema_sdl(&self) -> Json<GraphqlResponse> {
95 let sdl = self.graphql_schema.sdl();
96 Json(GraphqlResponse {
97 result: serde_json::Value::String(sdl),
98 })
99 }
100
101 #[tool(
102 description = "Execute a GraphQL query or mutation against the koan music player. \
103 This is the primary interface for ALL operations — library browsing, playback control, \
104 queue management, favourites, snapshots, radio, devices.\n\n\
105 Call schema_sdl first to learn the full schema.\n\n\
106 Quick examples:\n\
107 - Search: { tracks(search: \"aphex\") { edges { node { id title artist album } } } }\n\
108 - Filter: { albums(yearEnd: 1995, codec: \"FLAC\") { edges { node { title artistName date } } } }\n\
109 - Now playing: { nowPlaying { state positionMs track { title artist codec sampleRate } } }\n\
110 - Queue tracks: mutation { addToQueue(trackIds: [42, 43]) { ok addedCount } }\n\
111 - Play/pause: mutation { pause { ok } } / mutation { resume { ok } }\n\
112 - Snapshot: mutation { saveSnapshot(name: \"techno\") { ok } }\n\
113 - Radio: mutation { enableRadio { ok } }\n\n\
114 Track IDs are integers from the library. Queue item IDs are UUIDs from the queue.\n\
115 All string filters are case-insensitive substrings."
116 )]
117 fn graphql(
118 &self,
119 Parameters(params): Parameters<GraphqlParams>,
120 ) -> Result<Json<GraphqlResponse>, String> {
121 let schema = self.graphql_schema.clone();
122 let query = params.query;
123 let variables = params.variables;
124 let rt =
125 tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
126 let result = tokio::task::block_in_place(|| {
127 rt.block_on(crate::graphql::execute_in_process(
128 &schema,
129 &query,
130 variables,
131 mcp_role(),
132 ))
133 });
134 Ok(Json(GraphqlResponse { result }))
135 }
136}
137
138#[rmcp::tool_handler]
139impl ServerHandler for KoanMcpServer {
140 fn get_info(&self) -> ServerInfo {
141 ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
142 "koan is a bit-perfect macOS music player. You control it entirely via GraphQL.\n\n\
143 ## How to use\n\
144 1. Call `schema_sdl` to get the full GraphQL schema\n\
145 2. Use the `graphql` tool for ALL queries and mutations\n\n\
146 ## What you can do\n\
147 - **Discover music**: query `artists`, `albums`, `tracks` with rich filters \
148 (genre, year range, codec, sample rate, bit depth, duration, favourites)\n\
149 - **Control playback**: mutations `play`, `pause`, `resume`, `stop`, `next`, \
150 `previous`, `seek`\n\
151 - **Manage queue**: `addToQueue`, `replaceQueue`, `removeFromQueue`, `moveInQueue`, \
152 `clearQueue`, `undo`, `redo`\n\
153 - **Favourites**: `favourite`, `unfavourite`, `toggleFavourite` (auto-syncs to \
154 Subsonic/Navidrome). Filter any query with `favouritesOnly: true`\n\
155 - **Snapshots**: `saveSnapshot`, `restoreSnapshot`, `deleteSnapshot` — bank curated \
156 mixes and switch between them\n\
157 - **Radio**: `enableRadio`, `disableRadio` — auto-queues similar tracks\n\
158 - **Devices**: query `devices`; `setDevice`/`clearDevice` need `KOAN_MCP_ADMIN=1`\n\
159 - **History**: query `playHistory`, `similarArtists`\n\n\
160 ## Not available\n\
161 Admin mutations — `organize*` (moves files on disk), `updateConfig`, \
162 `triggerScan`, `createShare` — are refused unless `KOAN_MCP_ADMIN=1` is set.\n\n\
163 ## ID conventions\n\
164 - Track IDs: integers from the library database\n\
165 - Queue item IDs: UUIDs assigned when tracks enter the queue",
166 )
167 }
168}
169
170pub fn cmd_mcp() {
172 use koan_core::player::Player;
173 use rmcp::ServiceExt;
174
175 let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
177 let db_path = koan_core::config::db_path();
178
179 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
181
182 let server = KoanMcpServer::new(state, cmd_tx, db_path);
183
184 let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
186 rt.block_on(async {
187 let transport = rmcp::transport::io::stdio();
188 let service = server
189 .serve(transport)
190 .await
191 .expect("failed to start MCP server");
192 let _ = service.waiting().await;
193 });
194}
195
196#[cfg(test)]
201mod tests {
202 use super::*;
203 use koan_core::db::connection::Database;
204 use koan_core::db::queries;
205 use koan_core::player::commands::CommandChannel;
206 use tempfile::TempDir;
207
208 fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
209 let tmp = TempDir::new().unwrap();
210 let db_path = tmp.path().join("test.db");
211 let db = Database::open(&db_path).unwrap();
212 koan_core::db::schema::create_tables(&db.conn).unwrap();
213
214 let state = SharedPlayerState::new();
215 let ch = CommandChannel::new();
216 let tx = ch.tx.clone();
217
218 let server = KoanMcpServer::new(state, tx, db_path);
219 (server, ch, tmp)
220 }
221
222 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
223 let db = Database::open(db_path).unwrap();
224 let meta = queries::TrackMeta {
225 title: title.to_string(),
226 artist: artist.to_string(),
227 album_artist: Some(artist.to_string()),
228 album: album.to_string(),
229 track_number: Some(1),
230 disc: Some(1),
231 date: Some("2024".into()),
232 genre: Some("Electronic".into()),
233 duration_ms: Some(240000),
234 path: Some(format!(
235 "/tmp/test/{}.flac",
236 title.to_lowercase().replace(' ', "_")
237 )),
238 codec: Some("FLAC".into()),
239 sample_rate: Some(44100),
240 bit_depth: Some(16),
241 channels: Some(2),
242 bitrate: Some(1411),
243 size_bytes: Some(42_000_000),
244 mtime: Some(1700000000),
245 source: "local".into(),
246 remote_id: None,
247 remote_url: None,
248 album_remote_id: None,
249 artist_remote_id: None,
250 mbid: None,
251 album_added_at: None,
252 label: None,
253 };
254 queries::upsert_track(&db.conn, &meta).unwrap()
255 }
256
257 #[test]
258 fn schema_sdl_returns_schema() {
259 let (server, _ch, _tmp) = test_server();
260 let Json(resp) = server.schema_sdl();
261 let sdl = resp.result.as_str().unwrap();
262 assert!(sdl.contains("type QueryRoot"));
263 assert!(sdl.contains("type MutationRoot"));
264 assert!(sdl.contains("artists"));
265 assert!(sdl.contains("nowPlaying"));
266 }
267
268 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
269 async fn graphql_query_works() {
270 let (server, _ch, tmp) = test_server();
271 let db_path = tmp.path().join("test.db");
272 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
273
274 let result = server.graphql(Parameters(GraphqlParams {
275 query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
276 variables: None,
277 }));
278 assert!(result.is_ok());
279 let Json(resp) = result.unwrap();
280 let data = &resp.result["data"]["tracks"]["edges"];
281 assert_eq!(data.as_array().unwrap().len(), 1);
282 assert_eq!(data[0]["node"]["title"], "Windowlicker");
283 }
284
285 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
286 async fn graphql_mutation_works() {
287 let (server, _ch, _tmp) = test_server();
288 let result = server.graphql(Parameters(GraphqlParams {
289 query: "mutation { pause { ok message } }".into(),
290 variables: None,
291 }));
292 assert!(result.is_ok());
293 let Json(resp) = result.unwrap();
294 assert_eq!(resp.result["data"]["pause"]["ok"], true);
295 }
296
297 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
298 async fn graphql_now_playing_stopped() {
299 let (server, _ch, _tmp) = test_server();
300 let result = server.graphql(Parameters(GraphqlParams {
301 query: "{ nowPlaying { state positionMs } }".into(),
302 variables: None,
303 }));
304 assert!(result.is_ok());
305 let Json(resp) = result.unwrap();
306 assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
307 }
308
309 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
310 async fn graphql_library_stats() {
311 let (server, _ch, tmp) = test_server();
312 let db_path = tmp.path().join("test.db");
313 insert_test_track(&db_path, "T1", "A1", "Album1");
314
315 let result = server.graphql(Parameters(GraphqlParams {
316 query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
317 variables: None,
318 }));
319 assert!(result.is_ok());
320 let Json(resp) = result.unwrap();
321 assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
322 }
323}