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