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, ServerConfig};
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
42pub const USERNAME_HEADER: &str = "x-koan-username";
50pub const PASSWORD_HEADER: &str = "x-koan-password";
51
52#[derive(Clone)]
53pub struct KoanMcpServer {
54 #[allow(dead_code)]
55 tool_router: ToolRouter<Self>,
56 graphql_schema: crate::graphql::KoanSchema,
57 users: Option<Arc<crate::auth::password::PasswordVerifier>>,
59}
60
61impl KoanMcpServer {
62 pub fn new(
63 state: Arc<SharedPlayerState>,
64 cmd_tx: Sender<PlayerCommand>,
65 db_path: PathBuf,
66 ) -> Self {
67 let graphql_schema =
68 crate::graphql::build_schema(state.clone(), cmd_tx.clone(), db_path.clone(), None);
69 Self {
70 tool_router: Self::tool_router(),
71 graphql_schema,
72 users: None,
73 }
74 }
75
76 fn role(&self, extensions: &rmcp::model::Extensions) -> Result<koan_core::auth::Role, String> {
80 let Some(users) = &self.users else {
81 return Ok(mcp_role());
82 };
83 let parts = extensions.get::<axum::http::request::Parts>();
84 let get = |h: &str| {
85 parts
86 .and_then(|p| p.headers.get(h))
87 .and_then(|v| v.to_str().ok())
88 .map(str::trim)
89 .filter(|v| !v.is_empty())
90 };
91 match (get(USERNAME_HEADER), get(PASSWORD_HEADER)) {
92 (Some(u), Some(p)) => users
93 .verify(u, p)
94 .ok_or_else(|| "kōan rejected that username and password".to_string()),
95 _ if std::env::var("KOAN_MCP_REQUIRE_LOGIN").is_ok_and(|v| v == "1") => Err(format!(
96 "this kōan needs an account: send {USERNAME_HEADER} and {PASSWORD_HEADER}"
97 )),
98 _ => Ok(mcp_role()),
99 }
100 }
101}
102
103use rmcp::handler::server::wrapper::Parameters;
104use rmcp::tool;
105
106fn mcp_role() -> koan_core::auth::Role {
114 if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
115 koan_core::auth::Role::Admin
116 } else {
117 koan_core::auth::Role::User
118 }
119}
120
121#[tool_router]
122impl KoanMcpServer {
123 #[tool(
124 description = "The GraphQL schema for the user's music (kōan): their library and the \
125 players they listen on. Call this first, before `graphql`. It covers playing, pausing, \
126 skipping and queueing music on the user's phone and computers, what is playing now, \
127 and searching, browsing and making playlists from the music they own."
128 )]
129 fn schema_sdl(&self) -> Json<GraphqlResponse> {
130 let sdl = self.graphql_schema.sdl();
131 Json(GraphqlResponse {
132 result: serde_json::Value::String(sdl),
133 })
134 }
135
136 #[tool(
137 description = "Control the user's music and search their music library (kōan). Use it \
138 for any request about music they listen to or own: play something, pause, resume, skip, \
139 what's playing, what's next, add to or change the queue, find or recommend from their \
140 collection, playlists, favourites. \"Pause the music on my desktop\", \"play some \
141 jazz on my phone\" and \"what is this song\" are all this tool.\n\n\
142 Call schema_sdl first for the full schema. The user's phones and computers running \
143 kōan are `clients`; commands for them end in `OnClient`.\n\n\
144 Examples:\n\
145 - What's playing, where: { clients { name playing nowPlaying positionMs } }\n\
146 - Pause: mutation { controlClient(action: PAUSE) { ok message } }\n\
147 - Find music: { tracks(search: \"aphex\", first: 20) { edges { node { id title artist album } } } }\n\
148 - Play it: mutation { playOnClient(trackIds: [\"42\", \"43\"]) { ok message } }\n\n\
149 String filters are case-insensitive substrings."
150 )]
151 fn graphql(
152 &self,
153 Parameters(params): Parameters<GraphqlParams>,
154 extensions: rmcp::model::Extensions,
155 ) -> Result<Json<GraphqlResponse>, String> {
156 let schema = self.graphql_schema.clone();
157 let query = params.query;
158 let variables = params.variables;
159 let rt =
160 tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
161 let result = tokio::task::block_in_place(|| {
163 let role = self.role(&extensions)?;
164 Ok::<_, String>(rt.block_on(crate::graphql::execute_in_process(
165 &schema, &query, variables, role,
166 )))
167 })?;
168 Ok(Json(GraphqlResponse { result }))
169 }
170}
171
172#[rmcp::tool_handler]
173impl ServerHandler for KoanMcpServer {
174 fn get_info(&self) -> ServerConfig {
175 let instructions = if self.users.is_some() {
179 SERVER_INSTRUCTIONS
180 } else {
181 LOCAL_INSTRUCTIONS
182 };
183 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
184 .with_server_info(rmcp::model::Implementation::new(
185 "koan",
186 env!("CARGO_PKG_VERSION"),
187 ))
188 .with_instructions(instructions)
189 }
190}
191
192const SERVER_INSTRUCTIONS: &str = "kōan is the user's music: their whole music library, and the \
193phones and computers they listen on. Use it for anything about music they are playing or own — \
194\"pause the music\", \"play something like Polar Bear on my phone\", \"what's this song\", \
195\"skip to the Phace remix\", \"add their new album when it's downloaded\". Call `schema_sdl` \
196once, then do everything through `graphql`.
197
198## Where the music plays
199The user listens in kōan apps on their devices, linked to this server. Query \
200`clients { name platform playing nowPlaying album positionMs durationMs radio queue { trackId \
201title artist current } }` to see each device, what it is playing and what it has queued. Every \
202command about the user's music goes to a device:
203- `controlClient(action: PAUSE|RESUME|NEXT|PREVIOUS)`, `seekOnClient(positionMs)`
204- `playOnClient(trackIds, startAt)` replaces the queue and plays; `enqueue: true` appends
205- `playNextOnClient(trackIds)`, `jumpOnClient(trackId)` (skip to a track, queued or not), \
206`removeFromClient(trackIds)`, `clearClient`, `setClientRadio(enabled)`, `syncClient`
207- `queueOnClientWhenAdded(artist, album)` queues an album once it reaches the library, e.g. \
208one being downloaded with slsk's `grab`; `clientOrders` lists those waiting
209Leave `client` out unless the user named a device (\"my phone\", \"the desktop\": match it \
210against `clients` names and platforms). Without it the server picks the device that is \
211playing, else the one played most recently; if it answers that it cannot tell, ask the user \
212which device.
213
214**Never use the server's own player for the user's music.** `play`, `pause`, `resume`, \
215`next`, `previous`, `seek`, `nowPlaying`, `queue`, `addToQueue`, `replaceQueue`, \
216`playPlaylist` and the radio mutations drive a headless player on the server that nobody \
217hears; `nowPlaying` there reports nothing about what the user is listening to.
218
219## The library
220- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
221duration, favourites), `randomTracks`, `similarArtists`, `similarTracks`, `fuzzySearch`
222- Build a set from these, then send its track ids to a device with `playOnClient`. Track ids are \
223integers in queries; pass them to the client mutations as strings.
224- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
225- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `addToPlaylist`, \
226`setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`
227- History: `playHistory`
228- Sharing: `createShare(trackIds, description)` makes a public link anyone can open without an \
229account; confirm with the user first. `shares`, `updateShare`, `deleteShare` manage them.
230
231## Not available
232`organize*` (moves files on disk), `updateConfig` and `triggerScan` are refused unless \
233`KOAN_MCP_ADMIN=1` is set.";
234
235const LOCAL_INSTRUCTIONS: &str = "kōan is the user's music player on this machine and their \
236music library. Use it for anything about music they are playing or own — \"pause the music\", \
237\"play something like Polar Bear\", \"what's this song\". Call `schema_sdl` once, then do \
238everything through `graphql`.
239
240## Playback
241This player is what the user hears: `play`, `pause`, `resume`, `stop`, `next`, `previous`, \
242`seek`, `nowPlaying`; the queue with `queue`, `addToQueue`, `replaceQueue`, `removeFromQueue`, \
243`moveInQueue`, `clearQueue`, `undo`, `redo`; radio with `enableRadio`, `disableRadio`.
244
245## The library
246- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
247duration, favourites), `randomTracks`, `similarArtists`, `similarTracks`, `fuzzySearch`
248- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
249- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `saveQueueAsPlaylist`, \
250`addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`, `playPlaylist`
251- History: `playHistory`
252- Sharing: `createShare(trackIds, description)` makes a public link; confirm with the user first.
253
254## Not available
255`organize*` (moves files on disk), `updateConfig`, `triggerScan` and `setDevice` are refused \
256unless `KOAN_MCP_ADMIN=1` is set.
257
258## IDs
259Track IDs are integers from the library; queue item IDs are UUIDs from the queue.";
260
261pub fn spawn_http(
269 addr: std::net::SocketAddr,
270 state: Arc<SharedPlayerState>,
271 cmd_tx: Sender<PlayerCommand>,
272 pool: Arc<koan_core::db::pool::Pool>,
273) -> std::io::Result<std::thread::JoinHandle<()>> {
274 use rmcp::transport::streamable_http_server::{
275 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
276 };
277 let mut template = KoanMcpServer::new(state, cmd_tx, pool.path().to_path_buf());
278 template.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(pool)));
279 let listener = std::net::TcpListener::bind(addr)?;
281 listener.set_nonblocking(true)?;
282 std::thread::Builder::new()
283 .name("koan-mcp-http".into())
284 .spawn(move || {
285 let rt = tokio::runtime::Builder::new_multi_thread()
286 .enable_all()
287 .build()
288 .expect("failed to create tokio runtime");
289 rt.block_on(async move {
290 let service = StreamableHttpService::new(
291 move || Ok(template.clone()),
292 Arc::new(LocalSessionManager::default()),
293 StreamableHttpServerConfig::default().disable_allowed_hosts(),
297 );
298 let app = axum::Router::new().nest_service("/mcp", service);
299 let listener =
300 tokio::net::TcpListener::from_std(listener).expect("listener from std");
301 if let Err(e) = axum::serve(listener, app).await {
302 log::error!("MCP HTTP server stopped: {e}");
303 }
304 });
305 })
306}
307
308pub fn cmd_mcp() {
310 use koan_core::player::Player;
311 use rmcp::ServiceExt;
312
313 let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
315 let db_path = koan_core::config::db_path();
316
317 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
319
320 let server = KoanMcpServer::new(state, cmd_tx, db_path);
321
322 let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
324 rt.block_on(async {
325 let transport = rmcp::transport::io::stdio();
326 let service = server
327 .serve(transport)
328 .await
329 .expect("failed to start MCP server");
330 let _ = service.waiting().await;
331 });
332}
333
334#[cfg(test)]
339mod tests {
340 use super::*;
341 use koan_core::db::connection::Database;
342 use koan_core::db::queries;
343 use koan_core::player::commands::CommandChannel;
344 use tempfile::TempDir;
345
346 fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
347 let tmp = TempDir::new().unwrap();
348 let db_path = tmp.path().join("test.db");
349 let db = Database::open(&db_path).unwrap();
350 koan_core::db::schema::create_tables(&db.conn).unwrap();
351
352 let state = SharedPlayerState::new();
353 let ch = CommandChannel::new();
354 let tx = ch.tx.clone();
355
356 let server = KoanMcpServer::new(state, tx, db_path);
357 (server, ch, tmp)
358 }
359
360 fn with_headers(headers: &[(&str, &str)]) -> rmcp::model::Extensions {
361 let mut req = axum::http::Request::builder();
362 for (k, v) in headers {
363 req = req.header(*k, *v);
364 }
365 let (parts, ()) = req.body(()).unwrap().into_parts();
366 let mut ext = rmcp::model::Extensions::new();
367 ext.insert(parts);
368 ext
369 }
370
371 #[test]
372 fn gateway_headers_act_as_that_account() {
373 use koan_core::auth::Role;
374 let (mut server, _ch, tmp) = test_server();
375 let db_path = tmp.path().join("test.db");
376 let db = Database::open(&db_path).unwrap();
377 queries::auth::create_user(&db.conn, "owner", "sesame", Role::Admin).unwrap();
378 queries::auth::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
379 server.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
380 Arc::new(koan_core::db::pool::Pool::new(db_path)),
381 )));
382
383 let as_ = |u: &str, p: &str| {
384 server.role(&with_headers(&[(USERNAME_HEADER, u), (PASSWORD_HEADER, p)]))
385 };
386 assert_eq!(as_("owner", "sesame"), Ok(Role::Admin));
387 assert_eq!(as_("mate", "hunter22"), Ok(Role::Readonly));
388 assert!(as_("owner", "wrong").is_err());
389 assert_eq!(server.role(&with_headers(&[])), Ok(mcp_role()));
391 }
392
393 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
394 let db = Database::open(db_path).unwrap();
395 let meta = queries::TrackMeta {
396 title: title.to_string(),
397 artist: artist.to_string(),
398 album_artist: Some(artist.to_string()),
399 album: album.to_string(),
400 track_number: Some(1),
401 disc: Some(1),
402 date: Some("2024".into()),
403 genre: Some("Electronic".into()),
404 duration_ms: Some(240000),
405 path: Some(format!(
406 "/tmp/test/{}.flac",
407 title.to_lowercase().replace(' ', "_")
408 )),
409 codec: Some("FLAC".into()),
410 sample_rate: Some(44100),
411 bit_depth: Some(16),
412 channels: Some(2),
413 bitrate: Some(1411),
414 size_bytes: Some(42_000_000),
415 mtime: Some(1700000000),
416 source: "local".into(),
417 remote_id: None,
418 remote_url: None,
419 album_remote_id: None,
420 artist_remote_id: None,
421 mbid: None,
422 album_mbid: None,
423 album_added_at: None,
424 label: None,
425 };
426 queries::upsert_track(&db.conn, &meta).unwrap()
427 }
428
429 #[test]
430 fn schema_sdl_returns_schema() {
431 let (server, _ch, _tmp) = test_server();
432 let Json(resp) = server.schema_sdl();
433 let sdl = resp.result.as_str().unwrap();
434 assert!(sdl.contains("type QueryRoot"));
435 assert!(sdl.contains("type MutationRoot"));
436 assert!(sdl.contains("artists"));
437 assert!(sdl.contains("nowPlaying"));
438 }
439
440 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
441 async fn graphql_query_works() {
442 let (server, _ch, tmp) = test_server();
443 let db_path = tmp.path().join("test.db");
444 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
445
446 let result = server.graphql(
447 Parameters(GraphqlParams {
448 query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
449 variables: None,
450 }),
451 Default::default(),
452 );
453 assert!(result.is_ok());
454 let Json(resp) = result.unwrap();
455 let data = &resp.result["data"]["tracks"]["edges"];
456 assert_eq!(data.as_array().unwrap().len(), 1);
457 assert_eq!(data[0]["node"]["title"], "Windowlicker");
458 }
459
460 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
461 async fn graphql_mutation_works() {
462 let (server, _ch, _tmp) = test_server();
463 let result = server.graphql(
464 Parameters(GraphqlParams {
465 query: "mutation { pause { ok message } }".into(),
466 variables: None,
467 }),
468 Default::default(),
469 );
470 assert!(result.is_ok());
471 let Json(resp) = result.unwrap();
472 assert_eq!(resp.result["data"]["pause"]["ok"], true);
473 }
474
475 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
476 async fn graphql_now_playing_stopped() {
477 let (server, _ch, _tmp) = test_server();
478 let result = server.graphql(
479 Parameters(GraphqlParams {
480 query: "{ nowPlaying { state positionMs } }".into(),
481 variables: None,
482 }),
483 Default::default(),
484 );
485 assert!(result.is_ok());
486 let Json(resp) = result.unwrap();
487 assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
488 }
489
490 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
491 async fn graphql_library_stats() {
492 let (server, _ch, tmp) = test_server();
493 let db_path = tmp.path().join("test.db");
494 insert_test_track(&db_path, "T1", "A1", "Album1");
495
496 let result = server.graphql(
497 Parameters(GraphqlParams {
498 query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
499 variables: None,
500 }),
501 Default::default(),
502 );
503 assert!(result.is_ok());
504 let Json(resp) = result.unwrap();
505 assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
506 }
507}