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 crate::auth::password::Refused;
9use crossbeam_channel::Sender;
10use koan_core::player::commands::PlayerCommand;
11use koan_core::player::state::SharedPlayerState;
12use rmcp::handler::server::router::tool::ToolRouter;
13use rmcp::handler::server::wrapper::Json;
14use rmcp::model::{ServerCapabilities, ServerConfig};
15use rmcp::{ServerHandler, schemars, tool_router};
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Parameter types
20// ---------------------------------------------------------------------------
21
22#[derive(Debug, Deserialize, schemars::JsonSchema)]
23pub struct GraphqlParams {
24    #[schemars(
25        description = "GraphQL query or mutation string. Use the schema_sdl tool first to learn available types, queries, mutations, and filter parameters."
26    )]
27    pub query: String,
28    #[schemars(description = "Optional JSON object of query variables")]
29    pub variables: Option<serde_json::Value>,
30}
31
32// ---------------------------------------------------------------------------
33// Response types
34// ---------------------------------------------------------------------------
35
36/// GraphQL execution result wrapper — MCP spec requires outputSchema to be an object type.
37#[derive(Debug, Serialize, schemars::JsonSchema)]
38pub struct GraphqlResponse {
39    /// The GraphQL response JSON (contains data and/or errors fields).
40    pub result: serde_json::Value,
41}
42
43// ---------------------------------------------------------------------------
44// MCP Server
45// ---------------------------------------------------------------------------
46
47/// A koan account, sent by the MCP gateway with every request once the
48/// gateway has signed its user in. Only the HTTP transport reads them, and it
49/// is reachable from the gateway alone.
50pub const USERNAME_HEADER: &str = "x-koan-username";
51pub const PASSWORD_HEADER: &str = "x-koan-password";
52
53#[derive(Clone)]
54pub struct KoanMcpServer {
55    #[allow(dead_code)]
56    tool_router: ToolRouter<Self>,
57    graphql_schema: crate::graphql::KoanSchema,
58    /// Checks account headers; `None` on stdio, which has no headers.
59    users: Option<Arc<crate::auth::password::PasswordVerifier>>,
60}
61
62impl KoanMcpServer {
63    pub fn new(
64        state: Arc<SharedPlayerState>,
65        cmd_tx: Sender<PlayerCommand>,
66        pool: Arc<koan_core::db::pool::Pool>,
67    ) -> Self {
68        let graphql_schema = crate::graphql::build_schema(state, cmd_tx, pool, None);
69        Self {
70            tool_router: Self::tool_router(),
71            graphql_schema,
72            users: None,
73        }
74    }
75
76    /// Who a request acts as: the account in its headers, or the local user at
77    /// `mcp_role()` when it names none. With `KOAN_MCP_REQUIRE_LOGIN=1`, a
78    /// request naming no account is refused.
79    fn caller(&self, extensions: &rmcp::model::Extensions) -> Result<AuthUser, String> {
80        let local = AuthUser {
81            user_id: koan_core::db::queries::LOCAL_USER,
82            role: mcp_role(),
83            ..AuthUser::anonymous_admin()
84        };
85        let Some(users) = &self.users else {
86            return Ok(local);
87        };
88        let parts = extensions.get::<axum::http::request::Parts>();
89        let get = |h: &str| {
90            parts
91                .and_then(|p| p.headers.get(h))
92                .and_then(|v| v.to_str().ok())
93                .map(str::trim)
94                .filter(|v| !v.is_empty())
95        };
96        match (get(USERNAME_HEADER), get(PASSWORD_HEADER)) {
97            (Some(u), Some(p)) => match users.verify(u, p) {
98                // The account's own name: linked devices are scoped by it.
99                Ok((user_id, role)) => Ok(AuthUser {
100                    user_id,
101                    username: u.to_owned(),
102                    role,
103                }),
104                Err(Refused::Wrong) => Err("kōan rejected that username and password".into()),
105                Err(Refused::Busy) => Err("kōan is busy checking passwords; try again".into()),
106            },
107            _ if std::env::var("KOAN_MCP_REQUIRE_LOGIN").is_ok_and(|v| v == "1") => Err(format!(
108                "this kōan needs an account: send {USERNAME_HEADER} and {PASSWORD_HEADER}"
109            )),
110            _ => Ok(local),
111        }
112    }
113}
114
115use rmcp::handler::server::wrapper::Parameters;
116use rmcp::tool;
117
118/// Role the MCP `graphql` tool executes at.
119///
120/// The transport carries no credential, so anything reachable here is reachable
121/// by whoever can talk to the MCP process. `User` covers everything the tool
122/// advertises — browsing, playback, queue, favourites, playlists, radio — and
123/// leaves out the admin mutations that move files on disk (`organize*`), rewrite
124/// config, or change the output device. `KOAN_MCP_ADMIN=1` opts back in.
125fn mcp_role() -> koan_core::auth::Role {
126    if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
127        koan_core::auth::Role::Admin
128    } else {
129        koan_core::auth::Role::User
130    }
131}
132
133#[tool_router]
134impl KoanMcpServer {
135    #[tool(
136        description = "The GraphQL schema for the user's music (kōan): their library and the \
137        players they listen on. Call this first, before `graphql`. It covers playing, pausing, \
138        skipping and queueing music on the user's phone and computers, what is playing now, \
139        and searching, browsing and making playlists from the music they own."
140    )]
141    fn schema_sdl(&self) -> Json<GraphqlResponse> {
142        let sdl = self.graphql_schema.sdl();
143        Json(GraphqlResponse {
144            result: serde_json::Value::String(sdl),
145        })
146    }
147
148    #[tool(
149        description = "Control the user's music and search their music library (kōan). Use it \
150        for any request about music they listen to or own: play something, pause, resume, skip, \
151        what's playing, what's next, add to or change the queue, find or recommend from their \
152        collection, playlists, favourites. \"Pause the music on my desktop\", \"play some \
153        jazz on my phone\" and \"what is this song\" are all this tool.\n\n\
154        Call schema_sdl first for the full schema. The user's phones and computers running \
155        kōan are `clients`; commands for them end in `OnClient`.\n\n\
156        Examples:\n\
157        - What's playing, where: { clients { name playing nowPlaying positionMs } }\n\
158        - Pause: mutation { controlClient(action: PAUSE) { ok message } }\n\
159        - Find music: { tracks(search: \"aphex\", first: 20) { edges { node { id title artist album } } } }\n\
160        - Play it: mutation { playOnClient(trackIds: [\"42\", \"43\"]) { ok message } }\n\n\
161        String filters are case-insensitive substrings."
162    )]
163    fn graphql(
164        &self,
165        Parameters(params): Parameters<GraphqlParams>,
166        extensions: rmcp::model::Extensions,
167    ) -> Result<Json<GraphqlResponse>, String> {
168        let schema = self.graphql_schema.clone();
169        let query = params.query;
170        let variables = params.variables;
171        let rt =
172            tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
173        // Inside block_in_place too: a first sign-in runs argon2.
174        let result = tokio::task::block_in_place(|| {
175            let caller = self.caller(&extensions)?;
176            Ok::<_, String>(rt.block_on(crate::graphql::execute_in_process(
177                &schema, &query, variables, caller,
178            )))
179        })?;
180        Ok(Json(GraphqlResponse { result }))
181    }
182}
183
184#[rmcp::tool_handler]
185impl ServerHandler for KoanMcpServer {
186    fn get_info(&self) -> ServerConfig {
187        // Over HTTP this is a server: its own player is headless and nobody
188        // hears it, and what the user listens to is the apps linked to it. On
189        // stdio it is the user's own machine, and its player is the music.
190        let instructions = if self.users.is_some() {
191            SERVER_INSTRUCTIONS
192        } else {
193            LOCAL_INSTRUCTIONS
194        };
195        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
196            .with_server_info(rmcp::model::Implementation::new(
197                "koan",
198                env!("CARGO_PKG_VERSION"),
199            ))
200            .with_instructions(instructions)
201    }
202}
203
204const SERVER_INSTRUCTIONS: &str = "kōan is the user's music: their whole music library, and the \
205phones and computers they listen on. Use it for anything about music they are playing or own — \
206\"pause the music\", \"play something like Polar Bear on my phone\", \"what's this song\", \
207\"skip to the Phace remix\", \"add their new album when it's downloaded\". Call `schema_sdl` \
208once, then do everything through `graphql`.
209
210## Where the music plays
211The user listens in kōan apps on their devices, linked to this server. Query \
212`clients { name platform playing nowPlaying album positionMs durationMs radio queue { trackId \
213title artist current } }` to see each device, what it is playing and what it has queued. Every \
214command about the user's music goes to a device:
215- `controlClient(action: PAUSE|RESUME|NEXT|PREVIOUS)`, `seekOnClient(positionMs)`
216- `playOnClient(trackIds, startAt)` replaces the queue and plays; `enqueue: true` appends. \
217A phone iOS has suspended is not linked but is still reached. Music comes up there as a \
218notification to tap, since iOS lets no app start audio on its own from sleep; queue, radio and \
219other changes are applied as it wakes. The message says when a device was asleep: tell the user \
220to tap the notification
221- `playNextOnClient(trackIds)`, `jumpOnClient(trackId)` (skip to a track, queued or not), \
222`removeFromClient(trackIds)`, `clearClient`, `setClientRadio(enabled)`, `syncClient`
223- **Making a playlist the user asked for** (\"make me a cyberpunk playlist\"): research what \
224fits, find each track in the library, `createPlaylist` with those in order. For picks the \
225library lacks, fetch the album with slsk's `grab`, then `addToPlaylistWhenAdded(playlistId, \
226artist, album, titles)` to add the wanted tracks once it is imported. Tell the user what is \
227there now and what is on its way.
228- Playlists made or edited here (`createPlaylist`, `setPlaylistTracks`…) reach every device \
229by themselves: linked ones sync at once, others when next opened. `syncClients` does the same \
230on request.
231- `evictOnClients(trackIds)` makes every linked device drop its downloaded copies of those \
232tracks: when a track plays as noise or glitches, after the file on the server is replaced
233- `queueOnClientWhenAdded(artist, album)` queues an album once it reaches the library, e.g. \
234one being downloaded with slsk's `grab`; `clientOrders` lists those waiting
235Leave `client` out unless the user named a device (\"my phone\", \"the desktop\": match it \
236against `clients` names and platforms). Without it the server picks the device that is \
237playing, else the one played most recently; if it answers that it cannot tell, ask the user \
238which device.
239
240**Act on what the user asks; do not second-guess it from reported state.** \"Pause\", \
241\"skip\" and \"resume\" go straight to `controlClient`: the user can hear the device and you \
242cannot, and a report can be stale or, from an older app (`playing: null`), absent.
243
244**Never use the server's own player for the user's music.** `play`, `pause`, `resume`, \
245`next`, `previous`, `seek`, `nowPlaying`, `queue`, `addToQueue`, `replaceQueue`, \
246`playPlaylist` and the radio mutations drive a headless player on the server that nobody \
247hears; `nowPlaying` there reports nothing about what the user is listening to.
248
249## The library
250- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
251duration, favourites), `randomTracks`, `similarArtists`, `similarTracks`, `fuzzySearch`
252- Build a set from these, then send its track ids to a device with `playOnClient`. Track ids are \
253integers in queries; pass them to the client mutations as strings.
254- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
255- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `addToPlaylist`, \
256`setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`
257- History: `playHistory`
258- Sharing: `createShare(trackIds, description)` makes a public link anyone can open without an \
259account; confirm with the user first. `shares`, `updateShare`, `deleteShare` manage them.
260
261## Not available
262`organize*` (moves files on disk), `updateConfig` and `triggerScan` are refused unless \
263`KOAN_MCP_ADMIN=1` is set.";
264
265const LOCAL_INSTRUCTIONS: &str = "kōan is the user's music player on this machine and their \
266music library. Use it for anything about music they are playing or own — \"pause the music\", \
267\"play something like Polar Bear\", \"what's this song\". Call `schema_sdl` once, then do \
268everything through `graphql`.
269
270## Playback
271This player is what the user hears: `play`, `pause`, `resume`, `stop`, `next`, `previous`, \
272`seek`, `nowPlaying`; the queue with `queue`, `addToQueue`, `replaceQueue`, `removeFromQueue`, \
273`moveInQueue`, `clearQueue`, `undo`, `redo`; radio with `enableRadio`, `disableRadio`.
274
275## The library
276- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
277duration, favourites), `randomTracks`, `similarArtists`, `similarTracks`, `fuzzySearch`
278- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
279- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `saveQueueAsPlaylist`, \
280`addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`, `playPlaylist`
281- History: `playHistory`
282- Sharing: `createShare(trackIds, description)` makes a public link; confirm with the user first.
283
284## Not available
285`organize*` (moves files on disk), `updateConfig`, `triggerScan` and `setDevice` are refused \
286unless `KOAN_MCP_ADMIN=1` is set.
287
288## IDs
289Track IDs are integers from the library; queue item IDs are UUIDs from the queue.";
290
291/// Serve MCP over streamable HTTP at `addr`/mcp, on a thread of its own.
292///
293/// The gateway authenticates its user and then forwards a koan account in
294/// `x-koan-username` / `x-koan-password`, which set the role each request acts
295/// with. The headers are trusted to come from the gateway, so this listener
296/// must not be reachable from anywhere else: bind it to an address only the
297/// gateway can reach, and keep it off the public GraphQL port.
298pub fn spawn_http(
299    addr: std::net::SocketAddr,
300    state: Arc<SharedPlayerState>,
301    cmd_tx: Sender<PlayerCommand>,
302    pool: Arc<koan_core::db::pool::Pool>,
303) -> std::io::Result<std::thread::JoinHandle<()>> {
304    use rmcp::transport::streamable_http_server::{
305        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
306    };
307    let mut template = KoanMcpServer::new(state, cmd_tx, pool.clone());
308    template.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(pool)));
309    // Bound here rather than on the thread, so a taken port fails the start.
310    let listener = std::net::TcpListener::bind(addr)?;
311    listener.set_nonblocking(true)?;
312    std::thread::Builder::new()
313        .name("koan-mcp-http".into())
314        .spawn(move || {
315            let rt = tokio::runtime::Builder::new_multi_thread()
316                .enable_all()
317                .build()
318                .expect("failed to create tokio runtime");
319            rt.block_on(async move {
320                let service = StreamableHttpService::new(
321                    move || Ok(template.clone()),
322                    Arc::new(LocalSessionManager::default()),
323                    // The gateway forwards its own Host header, which rmcp's
324                    // DNS-rebinding allowlist would refuse; reachability is
325                    // what guards this listener.
326                    StreamableHttpServerConfig::default().disable_allowed_hosts(),
327                );
328                let app = axum::Router::new().nest_service("/mcp", service);
329                let listener =
330                    tokio::net::TcpListener::from_std(listener).expect("listener from std");
331                if let Err(e) = axum::serve(listener, app).await {
332                    log::error!("MCP HTTP server stopped: {e}");
333                }
334            });
335        })
336}
337
338/// Entry point for `koan mcp` — starts a headless player with an MCP server on stdio.
339pub fn cmd_mcp() {
340    use koan_core::player::Player;
341    use rmcp::ServiceExt;
342
343    // Validate DB is accessible before starting the server.
344    let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
345    let db_path = koan_core::config::db_path();
346
347    // Spawn the player engine (headless — no TUI).
348    let (state, _timeline, _viz, cmd_tx) = Player::spawn();
349
350    let pool = Arc::new(koan_core::db::pool::Pool::new(db_path));
351    let server = KoanMcpServer::new(state, cmd_tx, pool);
352
353    // Run the MCP server on the tokio runtime (blocking the main thread).
354    let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
355    rt.block_on(async {
356        let transport = rmcp::transport::io::stdio();
357        let service = server
358            .serve(transport)
359            .await
360            .expect("failed to start MCP server");
361        let _ = service.waiting().await;
362    });
363}
364
365// ---------------------------------------------------------------------------
366// Tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use koan_core::db::connection::Database;
373    use koan_core::db::queries;
374    use koan_core::player::commands::CommandChannel;
375    use tempfile::TempDir;
376
377    fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
378        let tmp = TempDir::new().unwrap();
379        let db_path = tmp.path().join("test.db");
380        let db = Database::open(&db_path).unwrap();
381        koan_core::db::schema::create_tables(&db.conn).unwrap();
382
383        let state = SharedPlayerState::new();
384        let ch = CommandChannel::new();
385        let tx = ch.tx.clone();
386
387        let server =
388            KoanMcpServer::new(state, tx, Arc::new(koan_core::db::pool::Pool::new(db_path)));
389        (server, ch, tmp)
390    }
391
392    fn with_headers(headers: &[(&str, &str)]) -> rmcp::model::Extensions {
393        let mut req = axum::http::Request::builder();
394        for (k, v) in headers {
395            req = req.header(*k, *v);
396        }
397        let (parts, ()) = req.body(()).unwrap().into_parts();
398        let mut ext = rmcp::model::Extensions::new();
399        ext.insert(parts);
400        ext
401    }
402
403    #[test]
404    fn gateway_headers_act_as_that_account() {
405        use koan_core::auth::Role;
406        let (mut server, _ch, tmp) = test_server();
407        let db_path = tmp.path().join("test.db");
408        let db = Database::open(&db_path).unwrap();
409        queries::auth::create_user(&db.conn, "owner", "sesame", Role::Admin).unwrap();
410        queries::auth::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
411        server.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
412            Arc::new(koan_core::db::pool::Pool::new(db_path)),
413        )));
414
415        let as_ = |u: &str, p: &str| {
416            server
417                .caller(&with_headers(&[(USERNAME_HEADER, u), (PASSWORD_HEADER, p)]))
418                .map(|c| (c.user_id, c.username, c.role))
419        };
420        // The account's own name, which its linked devices are scoped by.
421        assert_eq!(as_("owner", "sesame"), Ok((1, "owner".into(), Role::Admin)));
422        assert_eq!(
423            as_("mate", "hunter22"),
424            Ok((2, "mate".into(), Role::Readonly))
425        );
426        assert!(as_("owner", "wrong").is_err());
427        // No account named: the local user, at the transport's default role.
428        let local = server.caller(&with_headers(&[])).unwrap();
429        assert_eq!(
430            (local.user_id, local.role),
431            (queries::LOCAL_USER, mcp_role())
432        );
433    }
434
435    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
436        let db = Database::open(db_path).unwrap();
437        let meta = queries::TrackMeta {
438            title: title.to_string(),
439            artist: artist.to_string(),
440            album_artist: Some(artist.to_string()),
441            album: album.to_string(),
442            track_number: Some(1),
443            disc: Some(1),
444            date: Some("2024".into()),
445            genre: Some("Electronic".into()),
446            duration_ms: Some(240000),
447            path: Some(format!(
448                "/tmp/test/{}.flac",
449                title.to_lowercase().replace(' ', "_")
450            )),
451            codec: Some("FLAC".into()),
452            sample_rate: Some(44100),
453            bit_depth: Some(16),
454            channels: Some(2),
455            bitrate: Some(1411),
456            size_bytes: Some(42_000_000),
457            mtime: Some(1700000000),
458            source: "local".into(),
459            remote_id: None,
460            remote_url: None,
461            album_remote_id: None,
462            artist_remote_id: None,
463            mbid: None,
464            album_mbid: None,
465            album_added_at: None,
466            label: None,
467        };
468        queries::upsert_track(&db.conn, &meta).unwrap()
469    }
470
471    #[test]
472    fn schema_sdl_returns_schema() {
473        let (server, _ch, _tmp) = test_server();
474        let Json(resp) = server.schema_sdl();
475        let sdl = resp.result.as_str().unwrap();
476        assert!(sdl.contains("type QueryRoot"));
477        assert!(sdl.contains("type MutationRoot"));
478        assert!(sdl.contains("artists"));
479        assert!(sdl.contains("nowPlaying"));
480    }
481
482    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
483    async fn graphql_query_works() {
484        let (server, _ch, tmp) = test_server();
485        let db_path = tmp.path().join("test.db");
486        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
487
488        let result = server.graphql(
489            Parameters(GraphqlParams {
490                query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
491                variables: None,
492            }),
493            Default::default(),
494        );
495        assert!(result.is_ok());
496        let Json(resp) = result.unwrap();
497        let data = &resp.result["data"]["tracks"]["edges"];
498        assert_eq!(data.as_array().unwrap().len(), 1);
499        assert_eq!(data[0]["node"]["title"], "Windowlicker");
500    }
501
502    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
503    async fn graphql_mutation_works() {
504        let (server, _ch, _tmp) = test_server();
505        let result = server.graphql(
506            Parameters(GraphqlParams {
507                query: "mutation { pause { ok message } }".into(),
508                variables: None,
509            }),
510            Default::default(),
511        );
512        assert!(result.is_ok());
513        let Json(resp) = result.unwrap();
514        assert_eq!(resp.result["data"]["pause"]["ok"], true);
515    }
516
517    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
518    async fn graphql_now_playing_stopped() {
519        let (server, _ch, _tmp) = test_server();
520        let result = server.graphql(
521            Parameters(GraphqlParams {
522                query: "{ nowPlaying { state positionMs } }".into(),
523                variables: None,
524            }),
525            Default::default(),
526        );
527        assert!(result.is_ok());
528        let Json(resp) = result.unwrap();
529        assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
530    }
531
532    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
533    async fn graphql_library_stats() {
534        let (server, _ch, tmp) = test_server();
535        let db_path = tmp.path().join("test.db");
536        insert_test_track(&db_path, "T1", "A1", "Album1");
537
538        let result = server.graphql(
539            Parameters(GraphqlParams {
540                query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
541                variables: None,
542            }),
543            Default::default(),
544        );
545        assert!(result.is_ok());
546        let Json(resp) = result.unwrap();
547        assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
548    }
549}