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    /// `server.url`, where an MCP client fetches koan's icon from.
55    server_url: Option<String>,
56}
57
58impl KoanMcpServer {
59    /// `session`: whose saved session the playback-state mutations write,
60    /// the server's own (`ApiServerOpts::session`); `None` keeps none.
61    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    /// Who a request acts as: the account `bearer_gate` found its token for,
78    /// at most at `capped` role; on stdio, the local user at `mcp_role()`.
79    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
98/// Role the MCP `graphql` tool executes at.
99///
100/// The transport carries no credential, so anything reachable here is reachable
101/// by whoever can talk to the MCP process. `User` covers everything the tool
102/// advertises — browsing, playback, queue, favourites, playlists — and
103/// leaves out the admin mutations that move files on disk (`organize*`), rewrite
104/// config, or change the output device. `KOAN_MCP_ADMIN=1` opts back in.
105fn 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
113/// Mutations the MCP never runs, whoever is calling and at whatever role,
114/// `KOAN_MCP_ADMIN` included: everything that moves or rewrites files on disk,
115/// and the config that says where the library is. A model that has been
116/// misled, or is simply wrong, can then lose nobody any music. GraphQL itself
117/// still offers them to an admin.
118pub const DENIED_MUTATIONS: &[&str] = &["organizeExecute", "organizeUndo", "updateConfig"];
119
120/// Refuses `DENIED_MUTATIONS` as they resolve, so no alias, fragment or
121/// variable spelling of the query gets past it.
122struct 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
148/// The role an account acts at through the MCP: its own, but no higher than
149/// `mcp_role()`. Track titles, tags and share descriptions reach the model, and
150/// any of them may carry an instruction; capped, an admin account's model
151/// cannot be talked into moving files or rewriting config.
152pub 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            &params.query,
199            params.variables,
200            caller,
201        )
202        .await;
203        Json(GraphqlResponse { result })
204    }
205}
206
207impl KoanMcpServer {
208    /// Who this server is to a client, with the icon a client shows beside it.
209    /// Without an icon, clients guess from the domain and find its parent's.
210    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        // Over HTTP this is a server: its own player is headless and nobody
228        // hears it, and what the user listens to is the apps linked to it. On
229        // stdio it is the user's own machine, and its player is the music.
230        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
241/// How to choose music by style. Shared by both instruction sets: genre tags are
242/// sparse wherever the library lives.
243macro_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;
361/// Open event streams count against it, so it allows a few clients each with
362/// a stream and requests in flight.
363const MAX_CONCURRENT: usize = 64;
364
365/// `/mcp` on the main port, for clients holding a token from koan's own OAuth
366/// (`ui::oauth`). Each request acts as the account its token names, at that
367/// account's role.
368pub 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        // The main app's Host guard has already checked the Host. Cancelled at
387        // shutdown, so open event streams end rather than hold it up.
388        StreamableHttpServerConfig::default()
389            .disable_allowed_hosts()
390            .with_cancellation_token(shutdown),
391    );
392    // No request timeout: a session's GET is an event stream that stays open.
393    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
413/// Let a request with a valid access token through as its account. Without
414/// one, the 401 names the resource metadata, which is how a client finds where
415/// to sign in.
416async 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    // Someone who pasted the address into a browser: show them what it is for.
427    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            // Without `server_url` there is no OAuth to point the client at.
465            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
482/// Entry point for `koan mcp` — starts a headless player with an MCP server on stdio.
483pub fn cmd_mcp() {
484    use koan_core::player::Player;
485    use rmcp::ServiceExt;
486
487    // Validate DB is accessible before starting the server.
488    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    // Spawn the player engine (headless — no TUI).
492    let (state, _timeline, _viz, cmd_tx) = Player::spawn();
493
494    let pool = Arc::new(koan_core::db::pool::Pool::new(db_path));
495    // Its player is headless and restores nothing.
496    let server = KoanMcpServer::new(state, cmd_tx, pool, None);
497
498    // Run the MCP server on the tokio runtime (blocking the main thread).
499    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// ---------------------------------------------------------------------------
511// Tests
512// ---------------------------------------------------------------------------
513
514#[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        // stdio: the local user, at the transport's default role.
566        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    /// Through MCP the playback-state mutations write the server's session,
726    /// as `/graphql` does; a server keeping none refuses them.
727    #[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}