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 = "Get the full GraphQL schema in SDL format. CALL THIS FIRST to learn all \
125 available queries, mutations, types, and filter parameters. The schema is the complete \
126 reference for everything koan can do — library discovery, playback control, queue \
127 management, favourites, playlists, radio mode, device switching, and more."
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 = "Execute a GraphQL query or mutation against the koan music player. \
138 This is the primary interface for ALL operations — library browsing, playback control, \
139 queue management, favourites, playlists, radio, devices.\n\n\
140 Call schema_sdl first to learn the full schema.\n\n\
141 Quick examples:\n\
142 - Search: { tracks(search: \"aphex\") { edges { node { id title artist album } } } }\n\
143 - Filter: { albums(yearEnd: 1995, codec: \"FLAC\") { edges { node { title artistName date } } } }\n\
144 - Now playing: { nowPlaying { state positionMs track { title artist codec sampleRate } } }\n\
145 - Queue tracks: mutation { addToQueue(trackIds: [42, 43]) { ok addedCount } }\n\
146 - Play/pause: mutation { pause { ok } } / mutation { resume { ok } }\n\
147 - Playlist: mutation { saveQueueAsPlaylist(name: \"techno\") { id name } }\n\
148 - Radio: mutation { enableRadio { ok } }\n\n\
149 Track IDs are integers from the library. Queue item IDs are UUIDs from the queue.\n\
150 All string filters are case-insensitive substrings."
151 )]
152 fn graphql(
153 &self,
154 Parameters(params): Parameters<GraphqlParams>,
155 extensions: rmcp::model::Extensions,
156 ) -> Result<Json<GraphqlResponse>, String> {
157 let schema = self.graphql_schema.clone();
158 let query = params.query;
159 let variables = params.variables;
160 let rt =
161 tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
162 let result = tokio::task::block_in_place(|| {
164 let role = self.role(&extensions)?;
165 Ok::<_, String>(rt.block_on(crate::graphql::execute_in_process(
166 &schema, &query, variables, role,
167 )))
168 })?;
169 Ok(Json(GraphqlResponse { result }))
170 }
171}
172
173#[rmcp::tool_handler]
174impl ServerHandler for KoanMcpServer {
175 fn get_info(&self) -> ServerConfig {
176 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
177 .with_server_info(rmcp::model::Implementation::new(
178 "koan",
179 env!("CARGO_PKG_VERSION"),
180 ))
181 .with_instructions(
182 "koan is a bit-perfect music player. You control it entirely via GraphQL.\n\n\
183 ## How to use\n\
184 1. Call `schema_sdl` to get the full GraphQL schema\n\
185 2. Use the `graphql` tool for ALL queries and mutations\n\n\
186 ## What you can do\n\
187 - **Discover music**: query `artists`, `albums`, `tracks` with rich filters \
188 (genre, year range, codec, sample rate, bit depth, duration, favourites)\n\
189 - **Control playback**: mutations `play`, `pause`, `resume`, `stop`, `next`, \
190 `previous`, `seek`\n\
191 - **Manage queue**: `addToQueue`, `replaceQueue`, `removeFromQueue`, `moveInQueue`, \
192 `clearQueue`, `undo`, `redo`\n\
193 - **Favourites**: `favourite`, `unfavourite`, `toggleFavourite` (auto-syncs to \
194 Subsonic/Navidrome). Filter any query with `favouritesOnly: true`\n\
195 - **Playlists**: query `playlists`/`playlistTracks`; `createPlaylist`, \
196 `saveQueueAsPlaylist`, `addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, \
197 `deletePlaylist`, `playPlaylist`. Synced to Subsonic/Navidrome\n\
198 - **Radio**: `enableRadio`, `disableRadio` — auto-queues similar tracks\n\
199 - **Devices**: query `devices`; `setDevice`/`clearDevice` need `KOAN_MCP_ADMIN=1`\n\
200 - **History**: query `playHistory`, `similarArtists`\n\
201 - **Sharing**: `createShare(trackIds, description)` returns a public link anyone can \
202 open without an account; query `shares` to list them, `updateShare` to set an \
203 expiry, `deleteShare` to revoke one. A link is public, so confirm with the user \
204 before making one\n\n\
205 ## Not available\n\
206 Admin mutations — `organize*` (moves files on disk), `updateConfig`, \
207 `triggerScan` — are refused unless `KOAN_MCP_ADMIN=1` is set.\n\n\
208 ## ID conventions\n\
209 - Track IDs: integers from the library database\n\
210 - Queue item IDs: UUIDs assigned when tracks enter the queue",
211 )
212 }
213}
214
215pub fn spawn_http(
223 addr: std::net::SocketAddr,
224 state: Arc<SharedPlayerState>,
225 cmd_tx: Sender<PlayerCommand>,
226 pool: Arc<koan_core::db::pool::Pool>,
227) -> std::io::Result<std::thread::JoinHandle<()>> {
228 use rmcp::transport::streamable_http_server::{
229 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
230 };
231 let mut template = KoanMcpServer::new(state, cmd_tx, pool.path().to_path_buf());
232 template.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(pool)));
233 let listener = std::net::TcpListener::bind(addr)?;
235 listener.set_nonblocking(true)?;
236 std::thread::Builder::new()
237 .name("koan-mcp-http".into())
238 .spawn(move || {
239 let rt = tokio::runtime::Builder::new_multi_thread()
240 .enable_all()
241 .build()
242 .expect("failed to create tokio runtime");
243 rt.block_on(async move {
244 let service = StreamableHttpService::new(
245 move || Ok(template.clone()),
246 Arc::new(LocalSessionManager::default()),
247 StreamableHttpServerConfig::default().disable_allowed_hosts(),
251 );
252 let app = axum::Router::new().nest_service("/mcp", service);
253 let listener =
254 tokio::net::TcpListener::from_std(listener).expect("listener from std");
255 if let Err(e) = axum::serve(listener, app).await {
256 log::error!("MCP HTTP server stopped: {e}");
257 }
258 });
259 })
260}
261
262pub fn cmd_mcp() {
264 use koan_core::player::Player;
265 use rmcp::ServiceExt;
266
267 let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
269 let db_path = koan_core::config::db_path();
270
271 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
273
274 let server = KoanMcpServer::new(state, cmd_tx, db_path);
275
276 let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
278 rt.block_on(async {
279 let transport = rmcp::transport::io::stdio();
280 let service = server
281 .serve(transport)
282 .await
283 .expect("failed to start MCP server");
284 let _ = service.waiting().await;
285 });
286}
287
288#[cfg(test)]
293mod tests {
294 use super::*;
295 use koan_core::db::connection::Database;
296 use koan_core::db::queries;
297 use koan_core::player::commands::CommandChannel;
298 use tempfile::TempDir;
299
300 fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
301 let tmp = TempDir::new().unwrap();
302 let db_path = tmp.path().join("test.db");
303 let db = Database::open(&db_path).unwrap();
304 koan_core::db::schema::create_tables(&db.conn).unwrap();
305
306 let state = SharedPlayerState::new();
307 let ch = CommandChannel::new();
308 let tx = ch.tx.clone();
309
310 let server = KoanMcpServer::new(state, tx, db_path);
311 (server, ch, tmp)
312 }
313
314 fn with_headers(headers: &[(&str, &str)]) -> rmcp::model::Extensions {
315 let mut req = axum::http::Request::builder();
316 for (k, v) in headers {
317 req = req.header(*k, *v);
318 }
319 let (parts, ()) = req.body(()).unwrap().into_parts();
320 let mut ext = rmcp::model::Extensions::new();
321 ext.insert(parts);
322 ext
323 }
324
325 #[test]
326 fn gateway_headers_act_as_that_account() {
327 use koan_core::auth::Role;
328 let (mut server, _ch, tmp) = test_server();
329 let db_path = tmp.path().join("test.db");
330 let db = Database::open(&db_path).unwrap();
331 queries::auth::create_user(&db.conn, "owner", "sesame", Role::Admin).unwrap();
332 queries::auth::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
333 server.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
334 Arc::new(koan_core::db::pool::Pool::new(db_path)),
335 )));
336
337 let as_ = |u: &str, p: &str| {
338 server.role(&with_headers(&[(USERNAME_HEADER, u), (PASSWORD_HEADER, p)]))
339 };
340 assert_eq!(as_("owner", "sesame"), Ok(Role::Admin));
341 assert_eq!(as_("mate", "hunter22"), Ok(Role::Readonly));
342 assert!(as_("owner", "wrong").is_err());
343 assert_eq!(server.role(&with_headers(&[])), Ok(mcp_role()));
345 }
346
347 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
348 let db = Database::open(db_path).unwrap();
349 let meta = queries::TrackMeta {
350 title: title.to_string(),
351 artist: artist.to_string(),
352 album_artist: Some(artist.to_string()),
353 album: album.to_string(),
354 track_number: Some(1),
355 disc: Some(1),
356 date: Some("2024".into()),
357 genre: Some("Electronic".into()),
358 duration_ms: Some(240000),
359 path: Some(format!(
360 "/tmp/test/{}.flac",
361 title.to_lowercase().replace(' ', "_")
362 )),
363 codec: Some("FLAC".into()),
364 sample_rate: Some(44100),
365 bit_depth: Some(16),
366 channels: Some(2),
367 bitrate: Some(1411),
368 size_bytes: Some(42_000_000),
369 mtime: Some(1700000000),
370 source: "local".into(),
371 remote_id: None,
372 remote_url: None,
373 album_remote_id: None,
374 artist_remote_id: None,
375 mbid: None,
376 album_mbid: None,
377 album_added_at: None,
378 label: None,
379 };
380 queries::upsert_track(&db.conn, &meta).unwrap()
381 }
382
383 #[test]
384 fn schema_sdl_returns_schema() {
385 let (server, _ch, _tmp) = test_server();
386 let Json(resp) = server.schema_sdl();
387 let sdl = resp.result.as_str().unwrap();
388 assert!(sdl.contains("type QueryRoot"));
389 assert!(sdl.contains("type MutationRoot"));
390 assert!(sdl.contains("artists"));
391 assert!(sdl.contains("nowPlaying"));
392 }
393
394 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
395 async fn graphql_query_works() {
396 let (server, _ch, tmp) = test_server();
397 let db_path = tmp.path().join("test.db");
398 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
399
400 let result = server.graphql(
401 Parameters(GraphqlParams {
402 query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
403 variables: None,
404 }),
405 Default::default(),
406 );
407 assert!(result.is_ok());
408 let Json(resp) = result.unwrap();
409 let data = &resp.result["data"]["tracks"]["edges"];
410 assert_eq!(data.as_array().unwrap().len(), 1);
411 assert_eq!(data[0]["node"]["title"], "Windowlicker");
412 }
413
414 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
415 async fn graphql_mutation_works() {
416 let (server, _ch, _tmp) = test_server();
417 let result = server.graphql(
418 Parameters(GraphqlParams {
419 query: "mutation { pause { ok message } }".into(),
420 variables: None,
421 }),
422 Default::default(),
423 );
424 assert!(result.is_ok());
425 let Json(resp) = result.unwrap();
426 assert_eq!(resp.result["data"]["pause"]["ok"], true);
427 }
428
429 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
430 async fn graphql_now_playing_stopped() {
431 let (server, _ch, _tmp) = test_server();
432 let result = server.graphql(
433 Parameters(GraphqlParams {
434 query: "{ nowPlaying { state positionMs } }".into(),
435 variables: None,
436 }),
437 Default::default(),
438 );
439 assert!(result.is_ok());
440 let Json(resp) = result.unwrap();
441 assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
442 }
443
444 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
445 async fn graphql_library_stats() {
446 let (server, _ch, tmp) = test_server();
447 let db_path = tmp.path().join("test.db");
448 insert_test_track(&db_path, "T1", "A1", "Album1");
449
450 let result = server.graphql(
451 Parameters(GraphqlParams {
452 query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
453 variables: None,
454 }),
455 Default::default(),
456 );
457 assert!(result.is_ok());
458 let Json(resp) = result.unwrap();
459 assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
460 }
461}