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