Skip to main content

koan_server/
mcp.rs

1//! MCP (Model Context Protocol) server for koan.
2//!
3//! Exposes the GraphQL schema as MCP tools for Claude Desktop / MCP clients.
4
5use 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// ---------------------------------------------------------------------------
18// Parameter types
19// ---------------------------------------------------------------------------
20
21#[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// ---------------------------------------------------------------------------
32// Response types
33// ---------------------------------------------------------------------------
34
35/// GraphQL execution result wrapper — MCP spec requires outputSchema to be an object type.
36#[derive(Debug, Serialize, schemars::JsonSchema)]
37pub struct GraphqlResponse {
38    /// The GraphQL response JSON (contains data and/or errors fields).
39    pub result: serde_json::Value,
40}
41
42// ---------------------------------------------------------------------------
43// MCP Server
44// ---------------------------------------------------------------------------
45
46#[derive(Clone)]
47pub struct KoanMcpServer {
48    #[allow(dead_code)]
49    tool_router: ToolRouter<Self>,
50    graphql_schema: crate::graphql::KoanSchema,
51    /// A headless server: its own player is heard by nobody, and the music
52    /// plays on the devices linked to it.
53    headless: bool,
54    /// `sharing.public_url`, where an MCP client fetches koan's icon from.
55    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    /// Who a request acts as: the account `bearer_gate` found its token for,
74    /// at most at `capped` role; on stdio, the local user at `mcp_role()`.
75    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
94/// Role the MCP `graphql` tool executes at.
95///
96/// The transport carries no credential, so anything reachable here is reachable
97/// by whoever can talk to the MCP process. `User` covers everything the tool
98/// advertises — browsing, playback, queue, favourites, playlists — and
99/// leaves out the admin mutations that move files on disk (`organize*`), rewrite
100/// config, or change the output device. `KOAN_MCP_ADMIN=1` opts back in.
101fn 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
109/// Mutations the MCP never runs, whoever is calling and at whatever role,
110/// `KOAN_MCP_ADMIN` included: everything that moves or rewrites files on disk,
111/// and the config that says where the library is. A model that has been
112/// misled, or is simply wrong, can then lose nobody any music. GraphQL itself
113/// still offers them to an admin.
114pub const DENIED_MUTATIONS: &[&str] = &["organizeExecute", "organizeUndo", "updateConfig"];
115
116/// Refuses `DENIED_MUTATIONS` as they resolve, so no alias, fragment or
117/// variable spelling of the query gets past it.
118struct 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
144/// The role an account acts at through the MCP: its own, but no higher than
145/// `mcp_role()`. Track titles, tags and share descriptions reach the model, and
146/// any of them may carry an instruction; capped, an admin account's model
147/// cannot be talked into moving files or rewriting config.
148pub 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            &params.query,
195            params.variables,
196            caller,
197        )
198        .await;
199        Json(GraphqlResponse { result })
200    }
201}
202
203impl KoanMcpServer {
204    /// Who this server is to a client, with the icon a client shows beside it.
205    /// Without an icon, clients guess from the domain and find its parent's.
206    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        // Over HTTP this is a server: its own player is headless and nobody
224        // hears it, and what the user listens to is the apps linked to it. On
225        // stdio it is the user's own machine, and its player is the music.
226        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
237/// How to choose music by style. Shared by both instruction sets: genre tags are
238/// sparse wherever the library lives.
239macro_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- `playOnClient(trackIds, startAt)` replaces the queue and plays; `enqueue: true` appends. \
268A phone iOS has suspended is not linked but is still reached. Music comes up there as a \
269notification to tap, since iOS lets no app start audio on its own from sleep; queue and \
270other changes are applied as it wakes. The message says when a device was asleep: tell the user \
271to tap the notification
272- `playNextOnClient(trackIds)`, `jumpOnClient(trackId)` (skip to a track, queued or not), \
273`removeFromClient(trackIds)`, `clearClient`, `syncClient`
274- **Making a playlist the user asked for** (\"make me a cyberpunk playlist\"): research what \
275fits, find each track in the library, `createPlaylist` with those in order. For picks the \
276library lacks, fetch the album with slsk's `grab`, then `addToPlaylistWhenAdded(playlistId, \
277artist, album, titles)` to add the wanted tracks once it is imported. Tell the user what is \
278there now and what is on its way.
279- Playlists made or edited here (`createPlaylist`, `setPlaylistTracks`…) reach every device \
280by themselves: linked ones sync at once, others when next opened. `syncClients` does the same \
281on request.
282- `evictOnClients(trackIds)` makes every linked device drop its downloaded copies of those \
283tracks: when a track plays as noise or glitches, after the file on the server is replaced
284- `queueOnClientWhenAdded(artist, album)` queues an album once it reaches the library, e.g. \
285one being downloaded with slsk's `grab`; `clientOrders` lists those waiting
286Leave `client` out unless the user named a device (\"my phone\", \"the desktop\": match it \
287against `clients` names and platforms). Without it the server picks the device that is \
288playing, else the one played most recently; if it answers that it cannot tell, ask the user \
289which device.
290
291**Act on what the user asks; do not second-guess it from reported state.** \"Pause\", \
292\"skip\" and \"resume\" go straight to `controlClient`: the user can hear the device and you \
293cannot, and a report can be stale or, from an older app (`playing: null`), absent.
294
295**Never use the server's own player for the user's music.** `play`, `pause`, `resume`, \
296`next`, `previous`, `seek`, `nowPlaying`, `queue`, `addToQueue`, `replaceQueue`, \
297and `playPlaylist` drive a headless player on the server that nobody \
298hears; `nowPlaying` there reports nothing about what the user is listening to.
299
300## The library
301- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
302duration, favourites), `randomTracks`, `fuzzySearch`
303",
304    choosing_by_style!(),
305    "- Build a set from these, then send its track ids to a device with `playOnClient`. Track ids are \
306integers in queries; pass them to the client mutations as strings.
307- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
308- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `addToPlaylist`, \
309`setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`
310- History: `playHistory`
311- Sharing: `createShare(trackIds, description)` makes a public link anyone can open without an \
312account; confirm with the user first. `shares`, `updateShare`, `deleteShare` manage them.
313
314## Not available
315`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
316through MCP. Other admin mutations (`triggerScan`, user management) are refused unless \
317`KOAN_MCP_ADMIN=1` is set.");
318
319const LOCAL_INSTRUCTIONS: &str = concat!(
320    "kōan is the user's music player on this machine and their \
321music library. Use it for anything about music they are playing or own — \"pause the music\", \
322\"play something like Polar Bear\", \"what's this song\". Call `schema_sdl` once, then do \
323everything through `graphql`.
324
325## Playback
326This player is what the user hears: `play`, `pause`, `resume`, `stop`, `next`, `previous`, \
327`seek`, `nowPlaying`; the queue with `queue`, `addToQueue`, `replaceQueue`, `removeFromQueue`, \
328`moveInQueue`, `clearQueue`, `undo`, `redo`.
329
330## The library
331- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
332duration, favourites), `randomTracks`, `fuzzySearch`
333",
334    choosing_by_style!(),
335    "- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
336- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `saveQueueAsPlaylist`, \
337`addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`, `playPlaylist`
338- History: `playHistory`
339- Sharing: `createShare(trackIds, description)` makes a public link; confirm with the user first.
340
341## Not available
342`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
343through MCP. `triggerScan` and `setDevice` are refused unless `KOAN_MCP_ADMIN=1` is set.
344
345## IDs
346Track IDs are integers from the library; queue item IDs are UUIDs from the queue."
347);
348
349const MAX_BODY: usize = 1024 * 1024;
350/// Open event streams count against it, so it allows a few clients each with
351/// a stream and requests in flight.
352const MAX_CONCURRENT: usize = 64;
353
354/// `/mcp` on the main port, for clients holding a token from koan's own OAuth
355/// (`ui::oauth`). Each request acts as the account its token names, at that
356/// account's role.
357pub fn router(
358    state: Arc<SharedPlayerState>,
359    cmd_tx: Sender<PlayerCommand>,
360    auth: crate::auth::middleware::AuthState,
361    public_url: Option<String>,
362    headless: bool,
363    shutdown: tokio_util::sync::CancellationToken,
364) -> axum::Router {
365    use rmcp::transport::streamable_http_server::{
366        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
367    };
368    let mut template = KoanMcpServer::new(state, cmd_tx, auth.pool.clone());
369    template.headless = headless;
370    template.public_url = public_url.clone();
371    let service = StreamableHttpService::new(
372        move || Ok(template.clone()),
373        Arc::new(LocalSessionManager::default()),
374        // The main app's Host guard has already checked the Host. Cancelled at
375        // shutdown, so open event streams end rather than hold it up.
376        StreamableHttpServerConfig::default()
377            .disable_allowed_hosts()
378            .with_cancellation_token(shutdown),
379    );
380    // No request timeout: a session's GET is an event stream that stays open.
381    axum::Router::new()
382        .nest_service("/mcp", service)
383        .layer(tower_http::catch_panic::CatchPanicLayer::new())
384        .layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY))
385        .layer(axum::middleware::from_fn_with_state(
386            (auth, public_url),
387            bearer_gate,
388        ))
389        .layer(
390            tower::ServiceBuilder::new()
391                .layer(axum::error_handling::HandleErrorLayer::new(
392                    |_: tower::BoxError| async {
393                        (axum::http::StatusCode::SERVICE_UNAVAILABLE, "busy")
394                    },
395                ))
396                .load_shed()
397                .concurrency_limit(MAX_CONCURRENT),
398        )
399}
400
401/// Let a request with a valid access token through as its account. Without
402/// one, the 401 names the resource metadata, which is how a client finds where
403/// to sign in.
404async fn bearer_gate(
405    axum::extract::State((auth, public_url)): axum::extract::State<(
406        crate::auth::middleware::AuthState,
407        Option<String>,
408    )>,
409    mut req: axum::extract::Request,
410    next: axum::middleware::Next,
411) -> axum::response::Response {
412    use axum::http::{Method, StatusCode, header};
413    use axum::response::IntoResponse;
414    // Someone who pasted the address into a browser: show them what it is for.
415    let browser = req.method() == Method::GET
416        && !req.headers().contains_key(header::AUTHORIZATION)
417        && req
418            .headers()
419            .get(header::ACCEPT)
420            .and_then(|v| v.to_str().ok())
421            .is_some_and(|a| a.contains("text/html"));
422    if browser {
423        return axum::response::Redirect::to("/connect").into_response();
424    }
425    let user = if auth.auth_enabled {
426        let token = req
427            .headers()
428            .get(header::AUTHORIZATION)
429            .and_then(|v| v.to_str().ok())
430            .and_then(|v| v.strip_prefix("Bearer "))
431            .and_then(|t| {
432                koan_core::auth::validate_scoped_token(
433                    &auth.public_pem,
434                    t,
435                    Some(koan_core::auth::MCP_SCOPE),
436                )
437                .ok()
438            });
439        match token {
440            Some(claims) => crate::auth::current_user(&auth.pool, claims).await,
441            None => None,
442        }
443    } else {
444        Some(AuthUser::anonymous_admin())
445    };
446    match user {
447        Some(user) => {
448            req.extensions_mut().insert(user);
449            next.run(req).await
450        }
451        None => {
452            // Without `public_url` there is no OAuth to point the client at.
453            let challenge = match public_url.as_deref().map(|u| u.trim_end_matches('/')) {
454                Some(base) => format!(
455                    "Bearer resource_metadata=\"{base}{}\"",
456                    crate::ui::RESOURCE_METADATA
457                ),
458                None => "Bearer".to_owned(),
459            };
460            (
461                StatusCode::UNAUTHORIZED,
462                [(header::WWW_AUTHENTICATE, challenge)],
463                "sign in to kōan",
464            )
465                .into_response()
466        }
467    }
468}
469
470/// Entry point for `koan mcp` — starts a headless player with an MCP server on stdio.
471pub fn cmd_mcp() {
472    use koan_core::player::Player;
473    use rmcp::ServiceExt;
474
475    // Validate DB is accessible before starting the server.
476    let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
477    let db_path = koan_core::config::db_path();
478
479    // Spawn the player engine (headless — no TUI).
480    let (state, _timeline, _viz, cmd_tx) = Player::spawn();
481
482    let pool = Arc::new(koan_core::db::pool::Pool::new(db_path));
483    let server = KoanMcpServer::new(state, cmd_tx, pool);
484
485    // Run the MCP server on the tokio runtime (blocking the main thread).
486    let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
487    rt.block_on(async {
488        let transport = rmcp::transport::io::stdio();
489        let service = server
490            .serve(transport)
491            .await
492            .expect("failed to start MCP server");
493        let _ = service.waiting().await;
494    });
495}
496
497// ---------------------------------------------------------------------------
498// Tests
499// ---------------------------------------------------------------------------
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use koan_core::db::connection::Database;
505    use koan_core::db::queries;
506    use koan_core::player::commands::CommandChannel;
507    use tempfile::TempDir;
508
509    fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
510        let tmp = TempDir::new().unwrap();
511        let db_path = tmp.path().join("test.db");
512        let db = Database::open(&db_path).unwrap();
513        koan_core::db::schema::create_tables(&db.conn).unwrap();
514
515        let state = SharedPlayerState::new();
516        let ch = CommandChannel::new();
517        let tx = ch.tx.clone();
518
519        let server =
520            KoanMcpServer::new(state, tx, Arc::new(koan_core::db::pool::Pool::new(db_path)));
521        (server, ch, tmp)
522    }
523
524    fn as_user(user: AuthUser) -> rmcp::model::Extensions {
525        let (mut parts, ()) = axum::http::Request::new(()).into_parts();
526        parts.extensions.insert(user);
527        let mut ext = rmcp::model::Extensions::new();
528        ext.insert(parts);
529        ext
530    }
531
532    #[test]
533    fn a_token_acts_as_its_account_with_admin_capped() {
534        use koan_core::auth::Role;
535        let (server, _ch, _tmp) = test_server();
536        let user = |role| AuthUser {
537            user_id: 7,
538            username: "mate".into(),
539            role,
540        };
541        let c = server.caller(&as_user(user(Role::Admin)));
542        assert_eq!((c.user_id, c.username.as_str()), (7, "mate"));
543        assert_eq!(c.role, capped(Role::Admin));
544        assert_eq!(
545            server.caller(&as_user(user(Role::Readonly))).role,
546            Role::Readonly
547        );
548        // stdio: the local user, at the transport's default role.
549        let local = server.caller(&Default::default());
550        assert_eq!(
551            (local.user_id, local.role),
552            (queries::LOCAL_USER, mcp_role())
553        );
554    }
555
556    #[tokio::test]
557    async fn a_browser_opening_mcp_is_shown_how_to_connect() {
558        use tower::ServiceExt as _;
559        let (_server, ch, tmp) = test_server();
560        let auth = crate::auth::middleware::AuthState {
561            public_pem: Arc::new(Vec::new()),
562            auth_enabled: true,
563            introspection_key: None,
564            pool: Arc::new(koan_core::db::pool::Pool::new(tmp.path().join("test.db"))),
565        };
566        let app = router(
567            SharedPlayerState::new(),
568            ch.tx.clone(),
569            auth,
570            None,
571            true,
572            Default::default(),
573        );
574        let req = |accept: &str| {
575            axum::http::Request::get("/mcp")
576                .header(axum::http::header::ACCEPT, accept)
577                .body(axum::body::Body::empty())
578                .unwrap()
579        };
580        let r = app.clone().oneshot(req("text/html,*/*")).await.unwrap();
581        assert_eq!(r.headers()[axum::http::header::LOCATION], "/connect");
582        let r = app.oneshot(req("text/event-stream")).await.unwrap();
583        assert_eq!(r.status(), axum::http::StatusCode::UNAUTHORIZED);
584    }
585
586    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
587    async fn mutations_that_touch_files_are_refused_whoever_asks() {
588        use koan_core::auth::Role;
589        let (server, _ch, _tmp) = test_server();
590        let admin = as_user(AuthUser {
591            user_id: 1,
592            username: "owner".into(),
593            role: Role::Admin,
594        });
595        let Json(resp) = server
596            .graphql(
597                Parameters(GraphqlParams {
598                    query: "mutation { undo: organizeUndo { ok } }".into(),
599                    variables: None,
600                }),
601                admin,
602            )
603            .await;
604        let errors = resp.result["errors"].to_string();
605        assert!(errors.contains("not available through MCP"), "{errors}");
606    }
607
608    #[test]
609    fn a_server_with_an_address_names_its_icon() {
610        let (mut server, _ch, _tmp) = test_server();
611        assert!(server.get_info().server_info.icons.is_none());
612        server.public_url = Some("https://koan.test/".into());
613        let icons = server.get_info().server_info.icons.unwrap();
614        assert_eq!(icons[0].src, "https://koan.test/ui/assets/icon-192.png");
615    }
616
617    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
618        let db = Database::open(db_path).unwrap();
619        let meta = queries::TrackMeta {
620            title: title.to_string(),
621            artist: artist.to_string(),
622            album_artist: Some(artist.to_string()),
623            album: album.to_string(),
624            track_number: Some(1),
625            disc: Some(1),
626            date: Some("2024".into()),
627            genre: Some("Electronic".into()),
628            duration_ms: Some(240000),
629            path: Some(format!(
630                "/tmp/test/{}.flac",
631                title.to_lowercase().replace(' ', "_")
632            )),
633            codec: Some("FLAC".into()),
634            sample_rate: Some(44100),
635            bit_depth: Some(16),
636            channels: Some(2),
637            bitrate: Some(1411),
638            size_bytes: Some(42_000_000),
639            mtime: Some(1700000000),
640            source: "local".into(),
641            remote_id: None,
642            remote_url: None,
643            album_remote_id: None,
644            artist_remote_id: None,
645            mbid: None,
646            album_mbid: None,
647            album_added_at: None,
648            label: None,
649        };
650        queries::upsert_track(&db.conn, &meta).unwrap()
651    }
652
653    #[test]
654    fn schema_sdl_returns_schema() {
655        let (server, _ch, _tmp) = test_server();
656        let Json(resp) = server.schema_sdl();
657        let sdl = resp.result.as_str().unwrap();
658        assert!(sdl.contains("type QueryRoot"));
659        assert!(sdl.contains("type MutationRoot"));
660        assert!(sdl.contains("artists"));
661        assert!(sdl.contains("nowPlaying"));
662    }
663
664    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
665    async fn graphql_query_works() {
666        let (server, _ch, tmp) = test_server();
667        let db_path = tmp.path().join("test.db");
668        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
669
670        let result = server
671            .graphql(
672                Parameters(GraphqlParams {
673                    query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#
674                        .into(),
675                    variables: None,
676                }),
677                Default::default(),
678            )
679            .await;
680        let Json(resp) = result;
681        let data = &resp.result["data"]["tracks"]["edges"];
682        assert_eq!(data.as_array().unwrap().len(), 1);
683        assert_eq!(data[0]["node"]["title"], "Windowlicker");
684    }
685
686    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
687    async fn graphql_mutation_works() {
688        let (server, _ch, _tmp) = test_server();
689        let result = server
690            .graphql(
691                Parameters(GraphqlParams {
692                    query: "mutation { pause { ok message } }".into(),
693                    variables: None,
694                }),
695                Default::default(),
696            )
697            .await;
698        let Json(resp) = result;
699        assert_eq!(resp.result["data"]["pause"]["ok"], true);
700    }
701
702    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
703    async fn graphql_now_playing_stopped() {
704        let (server, _ch, _tmp) = test_server();
705        let result = server
706            .graphql(
707                Parameters(GraphqlParams {
708                    query: "{ nowPlaying { state positionMs } }".into(),
709                    variables: None,
710                }),
711                Default::default(),
712            )
713            .await;
714        let Json(resp) = result;
715        assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
716    }
717
718    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
719    async fn graphql_library_stats() {
720        let (server, _ch, tmp) = test_server();
721        let db_path = tmp.path().join("test.db");
722        insert_test_track(&db_path, "T1", "A1", "Album1");
723
724        let result = server
725            .graphql(
726                Parameters(GraphqlParams {
727                    query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
728                    variables: None,
729                }),
730                Default::default(),
731            )
732            .await;
733        let Json(resp) = result;
734        assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
735    }
736}