1use std::sync::Arc;
6
7use crate::auth::AuthUser;
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
42#[derive(Clone)]
47pub struct KoanMcpServer {
48 #[allow(dead_code)]
49 tool_router: ToolRouter<Self>,
50 graphql_schema: crate::graphql::KoanSchema,
51 headless: bool,
54 server_url: Option<String>,
56}
57
58impl KoanMcpServer {
59 pub fn new(
62 state: Arc<SharedPlayerState>,
63 cmd_tx: Sender<PlayerCommand>,
64 pool: Arc<koan_core::db::pool::Pool>,
65 session: Option<&'static str>,
66 ) -> Self {
67 let graphql_schema =
68 crate::graphql::build_schema_extended(state, cmd_tx, pool, session, Denylist);
69 Self {
70 tool_router: Self::tool_router(),
71 graphql_schema,
72 headless: false,
73 server_url: None,
74 }
75 }
76
77 fn caller(&self, extensions: &rmcp::model::Extensions) -> AuthUser {
80 extensions
81 .get::<axum::http::request::Parts>()
82 .and_then(|p| p.extensions.get::<AuthUser>())
83 .map(|u| AuthUser {
84 role: capped(u.role),
85 ..u.clone()
86 })
87 .unwrap_or_else(|| AuthUser {
88 user_id: koan_core::db::queries::LOCAL_USER,
89 role: mcp_role(),
90 ..AuthUser::anonymous_admin()
91 })
92 }
93}
94
95use rmcp::handler::server::wrapper::Parameters;
96use rmcp::tool;
97
98fn mcp_role() -> koan_core::auth::Role {
106 if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
107 koan_core::auth::Role::Admin
108 } else {
109 koan_core::auth::Role::User
110 }
111}
112
113pub const DENIED_MUTATIONS: &[&str] = &["organizeExecute", "organizeUndo", "updateConfig"];
119
120struct Denylist;
123
124impl async_graphql::extensions::ExtensionFactory for Denylist {
125 fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
126 Arc::new(Denylist)
127 }
128}
129
130#[async_trait::async_trait]
131impl async_graphql::extensions::Extension for Denylist {
132 async fn resolve(
133 &self,
134 ctx: &async_graphql::extensions::ExtensionContext<'_>,
135 info: async_graphql::extensions::ResolveInfo<'_>,
136 next: async_graphql::extensions::NextResolve<'_>,
137 ) -> async_graphql::ServerResult<Option<async_graphql::Value>> {
138 if info.parent_type == "MutationRoot" && DENIED_MUTATIONS.contains(&info.name) {
139 return Err(async_graphql::ServerError::new(
140 format!("{} is not available through MCP", info.name),
141 None,
142 ));
143 }
144 next.run(ctx, info).await
145 }
146}
147
148pub fn capped(role: koan_core::auth::Role) -> koan_core::auth::Role {
153 use koan_core::auth::Role;
154 match (role, mcp_role()) {
155 (Role::Admin, Role::User) => Role::User,
156 (role, _) => role,
157 }
158}
159
160#[tool_router]
161impl KoanMcpServer {
162 #[tool(
163 description = "The GraphQL schema for the user's music (kōan): their library and the \
164 players they listen on. Call this first, before `graphql`. It covers playing, pausing, \
165 skipping and queueing music on the user's phone and computers, what is playing now, \
166 and searching, browsing and making playlists from the music they own."
167 )]
168 fn schema_sdl(&self) -> Json<GraphqlResponse> {
169 let sdl = self.graphql_schema.sdl();
170 Json(GraphqlResponse {
171 result: serde_json::Value::String(sdl),
172 })
173 }
174
175 #[tool(
176 description = "Control the user's music and search their music library (kōan). Use it \
177 for any request about music they listen to or own: play something, pause, resume, skip, \
178 what's playing, what's next, add to or change the queue, find or recommend from their \
179 collection, playlists, favourites. \"Pause the music on my desktop\", \"play some \
180 jazz on my phone\" and \"what is this song\" are all this tool.\n\n\
181 Call schema_sdl first for the full schema. The user's phones and computers running \
182 kōan are `clients`; commands for them end in `OnClient`.\n\n\
183 Examples:\n\
184 - What's playing, where: { clients { name playing nowPlaying positionMs } }\n\
185 - Pause: mutation { controlClient(action: PAUSE) { ok message } }\n\
186 - Find music: { tracks(search: \"aphex\", first: 20) { edges { node { id title artist album } } } }\n\
187 - Play it: mutation { playOnClient(trackIds: [\"42\", \"43\"]) { ok message } }\n\n\
188 String filters are case-insensitive substrings."
189 )]
190 async fn graphql(
191 &self,
192 Parameters(params): Parameters<GraphqlParams>,
193 extensions: rmcp::model::Extensions,
194 ) -> Json<GraphqlResponse> {
195 let caller = self.caller(&extensions);
196 let result = crate::graphql::execute_in_process(
197 &self.graphql_schema,
198 ¶ms.query,
199 params.variables,
200 caller,
201 )
202 .await;
203 Json(GraphqlResponse { result })
204 }
205}
206
207impl KoanMcpServer {
208 fn implementation(&self) -> rmcp::model::Implementation {
211 let info =
212 rmcp::model::Implementation::new("koan", env!("CARGO_PKG_VERSION")).with_title("kōan");
213 match self.server_url.as_deref().map(|u| u.trim_end_matches('/')) {
214 Some(base) => info.with_website_url(base).with_icons(vec![
215 rmcp::model::Icon::new(format!("{base}/ui/assets/icon-192.png"))
216 .with_mime_type("image/png")
217 .with_sizes(vec!["192x192".into()]),
218 ]),
219 None => info,
220 }
221 }
222}
223
224#[rmcp::tool_handler]
225impl ServerHandler for KoanMcpServer {
226 fn get_info(&self) -> ServerConfig {
227 let instructions = if self.headless {
231 SERVER_INSTRUCTIONS
232 } else {
233 LOCAL_INSTRUCTIONS
234 };
235 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
236 .with_server_info(self.implementation())
237 .with_instructions(instructions)
238 }
239}
240
241macro_rules! choosing_by_style {
244 () => {
245 "- **Choosing music by style, mood or era** (\"psychedelic rock\", \"something for a rainy \
246Sunday\"): genre tags are sparse and inconsistent, so do not rely on `genre` filters. Use your \
247own knowledge, and research when unsure, to list many artists and albums that fit, then look \
248them all up in a single query with aliases (`a: artists(search: \"Can\") { … } b: …`) and \
249choose from the ones present. `search` matches any part of a name, ignoring case, so check that \
250a result is the artist you meant. If most are missing, read the library's artist names once \
251(`artists(first: 500, sortBy: TRACK_COUNT, sortDir: DESC) { edges { node { id name } } \
252pageInfo { hasNextPage endCursor } }`, then `after: endCursor` while `hasNextPage`) and pick \
253from them by what you know of each. Do not guess names one round at a time.
254"
255 };
256}
257
258const SERVER_INSTRUCTIONS: &str = concat!("kōan is the user's music: their whole music library, and the \
259phones and computers they listen on. Use it for anything about music they are playing or own — \
260\"pause the music\", \"play something like Polar Bear on my phone\", \"what's this song\", \
261\"skip to the Phace remix\", \"add their new album when it's downloaded\". Call `schema_sdl` \
262once, then do everything through `graphql`.
263
264## Where the music plays
265The user listens in kōan apps on their devices, linked to this server. Query \
266`clients { name platform playing nowPlaying album positionMs durationMs queue { trackId \
267title artist current } }` to see each device, what it is playing and what it has queued. Every \
268command about the user's music goes to a device:
269- `controlClient(action: PAUSE|RESUME|NEXT|PREVIOUS)`, `seekOnClient(positionMs)`
270- `setPlayModeOnClient(shuffle, repeat: OFF|QUEUE|ONE)`: shuffle plays the tracks not yet played in a random order, each once, without moving the queue, and turning it off carries on in queue order; `clients { shuffle repeat }` reports each device's modes
271- `setSleepTimerOnClient(minutes)` or `setSleepTimerOnClient(endOf: TRACK|RECORD)` (\"stop the music in 30 minutes\", \"after this album\"): it fades out and pauses, the queue kept; `cancelSleepTimerOnClient`; `clients { sleep { remainingMs endOf } }` shows what is set
272- `playOnClient(trackIds, startAt)` replaces the queue and plays, with shuffle and repeat turned off (`shuffle: true` queues the tracks in a random order); `enqueue: true` appends and leaves the modes alone. \
273A phone iOS has suspended is not linked but is still reached. Music comes up there as a \
274notification to tap, since iOS lets no app start audio on its own from sleep; queue and \
275other changes are applied as it wakes. The message says when a device was asleep: tell the user \
276to tap the notification
277- `playNextOnClient(trackIds)`, `jumpOnClient(trackId)` (skip to a track, queued or not), \
278`removeFromClient(trackIds)`, `clearClient`, `syncClient`
279- **Making a playlist the user asked for** (\"make me a cyberpunk playlist\"): research what \
280fits, find each track in the library, `createPlaylist` with those in order. For picks the \
281library lacks, fetch the album with slsk's `grab`, then `addToPlaylistWhenAdded(playlistId, \
282artist, album, titles)` to add the wanted tracks once it is imported. Tell the user what is \
283there now and what is on its way.
284- Playlists made or edited here (`createPlaylist`, `setPlaylistTracks`…) reach every device \
285by themselves: linked ones sync at once, others when next opened. `syncClients` does the same \
286on request.
287- `evictOnClients(trackIds)` makes every linked device drop its downloaded copies of those \
288tracks: when a track plays as noise or glitches, after the file on the server is replaced
289- `queueOnClientWhenAdded(artist, album)` queues an album once it reaches the library, e.g. \
290one being downloaded with slsk's `grab`; `clientOrders` lists those waiting
291Leave `client` out unless the user named a device (\"my phone\", \"the desktop\": match it \
292against `clients` names and platforms). Without it the server picks the device that is \
293playing, else the one played most recently; if it answers that it cannot tell, ask the user \
294which device.
295
296**Act on what the user asks; do not second-guess it from reported state.** \"Pause\", \
297\"skip\" and \"resume\" go straight to `controlClient`: the user can hear the device and you \
298cannot, and a report can be stale or, from an older app (`playing: null`), absent.
299
300**Never use the server's own player for the user's music.** `play`, `pause`, `resume`, \
301`next`, `previous`, `seek`, `nowPlaying`, `queue`, `addToQueue`, `replaceQueue`, \
302and `playPlaylist` drive a headless player on the server that nobody \
303hears; `nowPlaying` there reports nothing about what the user is listening to.
304
305## The library
306- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
307duration, favourites), `randomTracks`, `fuzzySearch`
308",
309 choosing_by_style!(),
310 "- Build a set from these, then send its track ids to a device with `playOnClient`. Track ids are \
311integers in queries; pass them to the client mutations as strings.
312- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
313- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `addToPlaylist`, \
314`setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`
315- Smart playlists stay up to date by themselves (\"what I played most last month\", \
316\"favourites I have not heard in a while\"): `createSmartPlaylist(name, rules)` and \
317`setPlaylistRules`; the rule format is in the schema's description of `createSmartPlaylist`. \
318Their contents cannot be edited (`readonly`); change the rules instead.
319- History: `playHistory`
320- Sharing: `createShare(trackIds, description)` makes a public link anyone can open without an \
321account; confirm with the user first. `shares`, `updateShare`, `deleteShare` manage them.
322
323## Not available
324`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
325through MCP. Other admin mutations (`triggerScan`, user management) are refused unless \
326`KOAN_MCP_ADMIN=1` is set.");
327
328const LOCAL_INSTRUCTIONS: &str = concat!(
329 "kōan is the user's music player on this machine and their \
330music library. Use it for anything about music they are playing or own — \"pause the music\", \
331\"play something like Polar Bear\", \"what's this song\". Call `schema_sdl` once, then do \
332everything through `graphql`.
333
334## Playback
335This player is what the user hears: `play`, `pause`, `resume`, `stop`, `next`, `previous`, \
336`seek`, `nowPlaying`; the queue with `queue`, `addToQueue`, `replaceQueue`, `removeFromQueue`, \
337`moveInQueue`, `clearQueue`, `undo`, `redo`.
338
339## The library
340- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
341duration, favourites), `randomTracks`, `fuzzySearch`
342",
343 choosing_by_style!(),
344 "- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
345- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `saveQueueAsPlaylist`, \
346`addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`, `playPlaylist`; \
347`createSmartPlaylist(name, rules)` and `setPlaylistRules` for ones that stay up to date by \
348themselves (rule format in the schema)
349- History: `playHistory`
350- Sharing: `createShare(trackIds, description)` makes a public link; confirm with the user first.
351
352## Not available
353`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
354through MCP. `triggerScan` and `setDevice` are refused unless `KOAN_MCP_ADMIN=1` is set.
355
356## IDs
357Track IDs are integers from the library; queue item IDs are UUIDs from the queue."
358);
359
360const MAX_BODY: usize = 1024 * 1024;
361const MAX_CONCURRENT: usize = 64;
364
365pub fn router(
369 state: Arc<SharedPlayerState>,
370 cmd_tx: Sender<PlayerCommand>,
371 auth: crate::auth::middleware::AuthState,
372 server_url: Option<String>,
373 headless: bool,
374 session: Option<&'static str>,
375 shutdown: tokio_util::sync::CancellationToken,
376) -> axum::Router {
377 use rmcp::transport::streamable_http_server::{
378 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
379 };
380 let mut template = KoanMcpServer::new(state, cmd_tx, auth.pool.clone(), session);
381 template.headless = headless;
382 template.server_url = server_url.clone();
383 let service = StreamableHttpService::new(
384 move || Ok(template.clone()),
385 Arc::new(LocalSessionManager::default()),
386 StreamableHttpServerConfig::default()
389 .disable_allowed_hosts()
390 .with_cancellation_token(shutdown),
391 );
392 axum::Router::new()
394 .nest_service("/mcp", service)
395 .layer(tower_http::catch_panic::CatchPanicLayer::new())
396 .layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY))
397 .layer(axum::middleware::from_fn_with_state(
398 (auth, server_url),
399 bearer_gate,
400 ))
401 .layer(
402 tower::ServiceBuilder::new()
403 .layer(axum::error_handling::HandleErrorLayer::new(
404 |_: tower::BoxError| async {
405 (axum::http::StatusCode::SERVICE_UNAVAILABLE, "busy")
406 },
407 ))
408 .load_shed()
409 .concurrency_limit(MAX_CONCURRENT),
410 )
411}
412
413async fn bearer_gate(
417 axum::extract::State((auth, server_url)): axum::extract::State<(
418 crate::auth::middleware::AuthState,
419 Option<String>,
420 )>,
421 mut req: axum::extract::Request,
422 next: axum::middleware::Next,
423) -> axum::response::Response {
424 use axum::http::{Method, StatusCode, header};
425 use axum::response::IntoResponse;
426 let browser = req.method() == Method::GET
428 && !req.headers().contains_key(header::AUTHORIZATION)
429 && req
430 .headers()
431 .get(header::ACCEPT)
432 .and_then(|v| v.to_str().ok())
433 .is_some_and(|a| a.contains("text/html"));
434 if browser {
435 return axum::response::Redirect::to("/connect").into_response();
436 }
437 let user = if auth.auth_enabled {
438 let token = req
439 .headers()
440 .get(header::AUTHORIZATION)
441 .and_then(|v| v.to_str().ok())
442 .and_then(|v| v.strip_prefix("Bearer "))
443 .and_then(|t| {
444 koan_core::auth::validate_scoped_token(
445 &auth.public_pem,
446 t,
447 Some(koan_core::auth::MCP_SCOPE),
448 )
449 .ok()
450 });
451 match token {
452 Some(claims) => crate::auth::current_user(&auth.pool, claims).await,
453 None => None,
454 }
455 } else {
456 Some(AuthUser::anonymous_admin())
457 };
458 match user {
459 Some(user) => {
460 req.extensions_mut().insert(user);
461 next.run(req).await
462 }
463 None => {
464 let challenge = match server_url.as_deref().map(|u| u.trim_end_matches('/')) {
466 Some(base) => format!(
467 "Bearer resource_metadata=\"{base}{}\"",
468 crate::ui::RESOURCE_METADATA
469 ),
470 None => "Bearer".to_owned(),
471 };
472 (
473 StatusCode::UNAUTHORIZED,
474 [(header::WWW_AUTHENTICATE, challenge)],
475 "sign in to kōan",
476 )
477 .into_response()
478 }
479 }
480}
481
482pub fn cmd_mcp() {
484 use koan_core::player::Player;
485 use rmcp::ServiceExt;
486
487 let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
489 let db_path = koan_core::config::db_path();
490
491 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
493
494 let pool = Arc::new(koan_core::db::pool::Pool::new(db_path));
495 let server = KoanMcpServer::new(state, cmd_tx, pool, None);
497
498 let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
500 rt.block_on(async {
501 let transport = rmcp::transport::io::stdio();
502 let service = server
503 .serve(transport)
504 .await
505 .expect("failed to start MCP server");
506 let _ = service.waiting().await;
507 });
508}
509
510#[cfg(test)]
515mod tests {
516 use super::*;
517 use koan_core::db::connection::Database;
518 use koan_core::db::queries;
519 use koan_core::player::commands::CommandChannel;
520 use tempfile::TempDir;
521
522 fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
523 let tmp = TempDir::new().unwrap();
524 let db_path = tmp.path().join("test.db");
525 let db = Database::open(&db_path).unwrap();
526 koan_core::db::schema::create_tables(&db.conn).unwrap();
527
528 let state = SharedPlayerState::new();
529 let ch = CommandChannel::new();
530 let tx = ch.tx.clone();
531
532 let server = KoanMcpServer::new(
533 state,
534 tx,
535 Arc::new(koan_core::db::pool::Pool::new(db_path)),
536 None,
537 );
538 (server, ch, tmp)
539 }
540
541 fn as_user(user: AuthUser) -> rmcp::model::Extensions {
542 let (mut parts, ()) = axum::http::Request::new(()).into_parts();
543 parts.extensions.insert(user);
544 let mut ext = rmcp::model::Extensions::new();
545 ext.insert(parts);
546 ext
547 }
548
549 #[test]
550 fn a_token_acts_as_its_account_with_admin_capped() {
551 use koan_core::auth::Role;
552 let (server, _ch, _tmp) = test_server();
553 let user = |role| AuthUser {
554 user_id: 7,
555 username: "mate".into(),
556 role,
557 };
558 let c = server.caller(&as_user(user(Role::Admin)));
559 assert_eq!((c.user_id, c.username.as_str()), (7, "mate"));
560 assert_eq!(c.role, capped(Role::Admin));
561 assert_eq!(
562 server.caller(&as_user(user(Role::Readonly))).role,
563 Role::Readonly
564 );
565 let local = server.caller(&Default::default());
567 assert_eq!(
568 (local.user_id, local.role),
569 (queries::LOCAL_USER, mcp_role())
570 );
571 }
572
573 #[tokio::test]
574 async fn a_browser_opening_mcp_is_shown_how_to_connect() {
575 use tower::ServiceExt as _;
576 let (_server, ch, tmp) = test_server();
577 let auth = crate::auth::middleware::AuthState {
578 public_pem: Arc::new(Vec::new()),
579 auth_enabled: true,
580 introspection_key: None,
581 pool: Arc::new(koan_core::db::pool::Pool::new(tmp.path().join("test.db"))),
582 };
583 let app = router(
584 SharedPlayerState::new(),
585 ch.tx.clone(),
586 auth,
587 None,
588 true,
589 None,
590 Default::default(),
591 );
592 let req = |accept: &str| {
593 axum::http::Request::get("/mcp")
594 .header(axum::http::header::ACCEPT, accept)
595 .body(axum::body::Body::empty())
596 .unwrap()
597 };
598 let r = app.clone().oneshot(req("text/html,*/*")).await.unwrap();
599 assert_eq!(r.headers()[axum::http::header::LOCATION], "/connect");
600 let r = app.oneshot(req("text/event-stream")).await.unwrap();
601 assert_eq!(r.status(), axum::http::StatusCode::UNAUTHORIZED);
602 }
603
604 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
605 async fn mutations_that_touch_files_are_refused_whoever_asks() {
606 use koan_core::auth::Role;
607 let (server, _ch, _tmp) = test_server();
608 let admin = as_user(AuthUser {
609 user_id: 1,
610 username: "owner".into(),
611 role: Role::Admin,
612 });
613 let Json(resp) = server
614 .graphql(
615 Parameters(GraphqlParams {
616 query: "mutation { undo: organizeUndo { ok } }".into(),
617 variables: None,
618 }),
619 admin,
620 )
621 .await;
622 let errors = resp.result["errors"].to_string();
623 assert!(errors.contains("not available through MCP"), "{errors}");
624 }
625
626 #[test]
627 fn a_server_with_an_address_names_its_icon() {
628 let (mut server, _ch, _tmp) = test_server();
629 assert!(server.get_info().server_info.icons.is_none());
630 server.server_url = Some("https://koan.test/".into());
631 let icons = server.get_info().server_info.icons.unwrap();
632 assert_eq!(icons[0].src, "https://koan.test/ui/assets/icon-192.png");
633 }
634
635 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
636 let db = Database::open(db_path).unwrap();
637 let meta = queries::TrackMeta {
638 title: title.to_string(),
639 artist: artist.to_string(),
640 album_artist: Some(artist.to_string()),
641 album: album.to_string(),
642 track_number: Some(1),
643 disc: Some(1),
644 disc_total: None,
645 date: Some("2024".into()),
646 genre: Some("Electronic".into()),
647 duration_ms: Some(240000),
648 path: Some(format!(
649 "/tmp/test/{}.flac",
650 title.to_lowercase().replace(' ', "_")
651 )),
652 server_path: None,
653 codec: Some("FLAC".into()),
654 sample_rate: Some(44100),
655 bit_depth: Some(16),
656 channels: Some(2),
657 bitrate: Some(1411),
658 size_bytes: Some(42_000_000),
659 mtime: Some(1700000000),
660 source: "local".into(),
661 remote_id: None,
662 remote_url: None,
663 album_remote_id: None,
664 artist_remote_id: None,
665 mbid: None,
666 album_mbid: None,
667 album_added_at: None,
668 label: None,
669 release_types: None,
670 original_date: None,
671 credits: Default::default(),
672 };
673 queries::upsert_track(&db.conn, &meta).unwrap()
674 }
675
676 #[test]
677 fn schema_sdl_returns_schema() {
678 let (server, _ch, _tmp) = test_server();
679 let Json(resp) = server.schema_sdl();
680 let sdl = resp.result.as_str().unwrap();
681 assert!(sdl.contains("type QueryRoot"));
682 assert!(sdl.contains("type MutationRoot"));
683 assert!(sdl.contains("artists"));
684 assert!(sdl.contains("nowPlaying"));
685 }
686
687 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
688 async fn graphql_query_works() {
689 let (server, _ch, tmp) = test_server();
690 let db_path = tmp.path().join("test.db");
691 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
692
693 let result = server
694 .graphql(
695 Parameters(GraphqlParams {
696 query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#
697 .into(),
698 variables: None,
699 }),
700 Default::default(),
701 )
702 .await;
703 let Json(resp) = result;
704 let data = &resp.result["data"]["tracks"]["edges"];
705 assert_eq!(data.as_array().unwrap().len(), 1);
706 assert_eq!(data[0]["node"]["title"], "Windowlicker");
707 }
708
709 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
710 async fn graphql_mutation_works() {
711 let (server, _ch, _tmp) = test_server();
712 let result = server
713 .graphql(
714 Parameters(GraphqlParams {
715 query: "mutation { pause { ok message } }".into(),
716 variables: None,
717 }),
718 Default::default(),
719 )
720 .await;
721 let Json(resp) = result;
722 assert_eq!(resp.result["data"]["pause"]["ok"], true);
723 }
724
725 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
728 async fn saving_the_session_works_through_mcp() {
729 let (_, ch, tmp) = test_server();
730 let pool = Arc::new(koan_core::db::pool::Pool::new(tmp.path().join("test.db")));
731 let save = |server: KoanMcpServer| async move {
732 let Json(resp) = server
733 .graphql(
734 Parameters(GraphqlParams {
735 query: "mutation { savePlaybackState { ok } }".into(),
736 variables: None,
737 }),
738 Default::default(),
739 )
740 .await;
741 resp.result
742 };
743 let terminal = koan_core::remote::link::LinkIdentity::TERMINAL_FILES;
744 let kept = KoanMcpServer::new(
745 SharedPlayerState::new(),
746 ch.tx.clone(),
747 pool.clone(),
748 Some(terminal),
749 );
750 let result = save(kept).await;
751 assert_eq!(result["data"]["savePlaybackState"]["ok"], true, "{result}");
752 let none = KoanMcpServer::new(SharedPlayerState::new(), ch.tx.clone(), pool, None);
753 let result = save(none).await;
754 assert!(result["errors"].is_array(), "{result}");
755 }
756
757 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
758 async fn graphql_now_playing_stopped() {
759 let (server, _ch, _tmp) = test_server();
760 let result = server
761 .graphql(
762 Parameters(GraphqlParams {
763 query: "{ nowPlaying { state positionMs } }".into(),
764 variables: None,
765 }),
766 Default::default(),
767 )
768 .await;
769 let Json(resp) = result;
770 assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
771 }
772
773 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
774 async fn graphql_library_stats() {
775 let (server, _ch, tmp) = test_server();
776 let db_path = tmp.path().join("test.db");
777 insert_test_track(&db_path, "T1", "A1", "Album1");
778
779 let result = server
780 .graphql(
781 Parameters(GraphqlParams {
782 query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
783 variables: None,
784 }),
785 Default::default(),
786 )
787 .await;
788 let Json(resp) = result;
789 assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
790 }
791}