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 = "Get the full GraphQL schema in SDL format. CALL THIS FIRST to learn all \
125        available queries, mutations, types, and filter parameters. The schema is the complete \
126        reference for everything koan can do — library discovery, playback control, queue \
127        management, favourites, playlists, radio mode, device switching, and more."
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 = "Execute a GraphQL query or mutation against the koan music player. \
138        This is the primary interface for ALL operations — library browsing, playback control, \
139        queue management, favourites, playlists, radio, devices.\n\n\
140        Call schema_sdl first to learn the full schema.\n\n\
141        Quick examples:\n\
142        - Search: { tracks(search: \"aphex\") { edges { node { id title artist album } } } }\n\
143        - Filter: { albums(yearEnd: 1995, codec: \"FLAC\") { edges { node { title artistName date } } } }\n\
144        - Now playing: { nowPlaying { state positionMs track { title artist codec sampleRate } } }\n\
145        - Queue tracks: mutation { addToQueue(trackIds: [42, 43]) { ok addedCount } }\n\
146        - Play/pause: mutation { pause { ok } } / mutation { resume { ok } }\n\
147        - Playlist: mutation { saveQueueAsPlaylist(name: \"techno\") { id name } }\n\
148        - Radio: mutation { enableRadio { ok } }\n\n\
149        Track IDs are integers from the library. Queue item IDs are UUIDs from the queue.\n\
150        All string filters are case-insensitive substrings."
151    )]
152    fn graphql(
153        &self,
154        Parameters(params): Parameters<GraphqlParams>,
155        extensions: rmcp::model::Extensions,
156    ) -> Result<Json<GraphqlResponse>, String> {
157        let schema = self.graphql_schema.clone();
158        let query = params.query;
159        let variables = params.variables;
160        let rt =
161            tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
162        // Inside block_in_place too: a first sign-in runs argon2.
163        let result = tokio::task::block_in_place(|| {
164            let role = self.role(&extensions)?;
165            Ok::<_, String>(rt.block_on(crate::graphql::execute_in_process(
166                &schema, &query, variables, role,
167            )))
168        })?;
169        Ok(Json(GraphqlResponse { result }))
170    }
171}
172
173#[rmcp::tool_handler]
174impl ServerHandler for KoanMcpServer {
175    fn get_info(&self) -> ServerConfig {
176        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
177            .with_server_info(rmcp::model::Implementation::new(
178                "koan",
179                env!("CARGO_PKG_VERSION"),
180            ))
181            .with_instructions(
182                "koan is a bit-perfect music player. You control it entirely via GraphQL.\n\n\
183             ## How to use\n\
184             1. Call `schema_sdl` to get the full GraphQL schema\n\
185             2. Use the `graphql` tool for ALL queries and mutations\n\n\
186             ## What you can do\n\
187             - **Discover music**: query `artists`, `albums`, `tracks` with rich filters \
188               (genre, year range, codec, sample rate, bit depth, duration, favourites)\n\
189             - **Control playback**: mutations `play`, `pause`, `resume`, `stop`, `next`, \
190               `previous`, `seek`\n\
191             - **Manage queue**: `addToQueue`, `replaceQueue`, `removeFromQueue`, `moveInQueue`, \
192               `clearQueue`, `undo`, `redo`\n\
193             - **Favourites**: `favourite`, `unfavourite`, `toggleFavourite` (auto-syncs to \
194               Subsonic/Navidrome). Filter any query with `favouritesOnly: true`\n\
195             - **Playlists**: query `playlists`/`playlistTracks`; `createPlaylist`, \
196               `saveQueueAsPlaylist`, `addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, \
197               `deletePlaylist`, `playPlaylist`. Synced to Subsonic/Navidrome\n\
198             - **Radio**: `enableRadio`, `disableRadio` — auto-queues similar tracks\n\
199             - **Play on the user's phone or Mac**: query `clients` for the koan apps \
200               linked to this server, then `playOnClient(trackIds, client)` to replace its \
201               queue (or `enqueue: true` to append), and `controlClient` to pause, resume or \
202               skip. Build the list with the library queries first; the music plays on that \
203               device, not on the server\n\
204             - **Devices**: query `devices`; `setDevice`/`clearDevice` need `KOAN_MCP_ADMIN=1`\n\
205             - **History**: query `playHistory`, `similarArtists`\n\
206             - **Sharing**: `createShare(trackIds, description)` returns a public link anyone can \
207               open without an account; query `shares` to list them, `updateShare` to set an \
208               expiry, `deleteShare` to revoke one. A link is public, so confirm with the user \
209               before making one\n\n\
210             ## Not available\n\
211             Admin mutations — `organize*` (moves files on disk), `updateConfig`, \
212             `triggerScan` — are refused unless `KOAN_MCP_ADMIN=1` is set.\n\n\
213             ## ID conventions\n\
214             - Track IDs: integers from the library database\n\
215             - Queue item IDs: UUIDs assigned when tracks enter the queue",
216            )
217    }
218}
219
220/// Serve MCP over streamable HTTP at `addr`/mcp, on a thread of its own.
221///
222/// The gateway authenticates its user and then forwards a koan account in
223/// `x-koan-username` / `x-koan-password`, which set the role each request acts
224/// with. The headers are trusted to come from the gateway, so this listener
225/// must not be reachable from anywhere else: bind it to an address only the
226/// gateway can reach, and keep it off the public GraphQL port.
227pub fn spawn_http(
228    addr: std::net::SocketAddr,
229    state: Arc<SharedPlayerState>,
230    cmd_tx: Sender<PlayerCommand>,
231    pool: Arc<koan_core::db::pool::Pool>,
232) -> std::io::Result<std::thread::JoinHandle<()>> {
233    use rmcp::transport::streamable_http_server::{
234        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
235    };
236    let mut template = KoanMcpServer::new(state, cmd_tx, pool.path().to_path_buf());
237    template.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(pool)));
238    // Bound here rather than on the thread, so a taken port fails the start.
239    let listener = std::net::TcpListener::bind(addr)?;
240    listener.set_nonblocking(true)?;
241    std::thread::Builder::new()
242        .name("koan-mcp-http".into())
243        .spawn(move || {
244            let rt = tokio::runtime::Builder::new_multi_thread()
245                .enable_all()
246                .build()
247                .expect("failed to create tokio runtime");
248            rt.block_on(async move {
249                let service = StreamableHttpService::new(
250                    move || Ok(template.clone()),
251                    Arc::new(LocalSessionManager::default()),
252                    // The gateway forwards its own Host header, which rmcp's
253                    // DNS-rebinding allowlist would refuse; reachability is
254                    // what guards this listener.
255                    StreamableHttpServerConfig::default().disable_allowed_hosts(),
256                );
257                let app = axum::Router::new().nest_service("/mcp", service);
258                let listener =
259                    tokio::net::TcpListener::from_std(listener).expect("listener from std");
260                if let Err(e) = axum::serve(listener, app).await {
261                    log::error!("MCP HTTP server stopped: {e}");
262                }
263            });
264        })
265}
266
267/// Entry point for `koan mcp` — starts a headless player with an MCP server on stdio.
268pub fn cmd_mcp() {
269    use koan_core::player::Player;
270    use rmcp::ServiceExt;
271
272    // Validate DB is accessible before starting the server.
273    let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
274    let db_path = koan_core::config::db_path();
275
276    // Spawn the player engine (headless — no TUI).
277    let (state, _timeline, _viz, cmd_tx) = Player::spawn();
278
279    let server = KoanMcpServer::new(state, cmd_tx, db_path);
280
281    // Run the MCP server on the tokio runtime (blocking the main thread).
282    let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
283    rt.block_on(async {
284        let transport = rmcp::transport::io::stdio();
285        let service = server
286            .serve(transport)
287            .await
288            .expect("failed to start MCP server");
289        let _ = service.waiting().await;
290    });
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use koan_core::db::connection::Database;
301    use koan_core::db::queries;
302    use koan_core::player::commands::CommandChannel;
303    use tempfile::TempDir;
304
305    fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
306        let tmp = TempDir::new().unwrap();
307        let db_path = tmp.path().join("test.db");
308        let db = Database::open(&db_path).unwrap();
309        koan_core::db::schema::create_tables(&db.conn).unwrap();
310
311        let state = SharedPlayerState::new();
312        let ch = CommandChannel::new();
313        let tx = ch.tx.clone();
314
315        let server = KoanMcpServer::new(state, tx, db_path);
316        (server, ch, tmp)
317    }
318
319    fn with_headers(headers: &[(&str, &str)]) -> rmcp::model::Extensions {
320        let mut req = axum::http::Request::builder();
321        for (k, v) in headers {
322            req = req.header(*k, *v);
323        }
324        let (parts, ()) = req.body(()).unwrap().into_parts();
325        let mut ext = rmcp::model::Extensions::new();
326        ext.insert(parts);
327        ext
328    }
329
330    #[test]
331    fn gateway_headers_act_as_that_account() {
332        use koan_core::auth::Role;
333        let (mut server, _ch, tmp) = test_server();
334        let db_path = tmp.path().join("test.db");
335        let db = Database::open(&db_path).unwrap();
336        queries::auth::create_user(&db.conn, "owner", "sesame", Role::Admin).unwrap();
337        queries::auth::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
338        server.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
339            Arc::new(koan_core::db::pool::Pool::new(db_path)),
340        )));
341
342        let as_ = |u: &str, p: &str| {
343            server.role(&with_headers(&[(USERNAME_HEADER, u), (PASSWORD_HEADER, p)]))
344        };
345        assert_eq!(as_("owner", "sesame"), Ok(Role::Admin));
346        assert_eq!(as_("mate", "hunter22"), Ok(Role::Readonly));
347        assert!(as_("owner", "wrong").is_err());
348        // No account named: the transport's default role.
349        assert_eq!(server.role(&with_headers(&[])), Ok(mcp_role()));
350    }
351
352    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
353        let db = Database::open(db_path).unwrap();
354        let meta = queries::TrackMeta {
355            title: title.to_string(),
356            artist: artist.to_string(),
357            album_artist: Some(artist.to_string()),
358            album: album.to_string(),
359            track_number: Some(1),
360            disc: Some(1),
361            date: Some("2024".into()),
362            genre: Some("Electronic".into()),
363            duration_ms: Some(240000),
364            path: Some(format!(
365                "/tmp/test/{}.flac",
366                title.to_lowercase().replace(' ', "_")
367            )),
368            codec: Some("FLAC".into()),
369            sample_rate: Some(44100),
370            bit_depth: Some(16),
371            channels: Some(2),
372            bitrate: Some(1411),
373            size_bytes: Some(42_000_000),
374            mtime: Some(1700000000),
375            source: "local".into(),
376            remote_id: None,
377            remote_url: None,
378            album_remote_id: None,
379            artist_remote_id: None,
380            mbid: None,
381            album_mbid: None,
382            album_added_at: None,
383            label: None,
384        };
385        queries::upsert_track(&db.conn, &meta).unwrap()
386    }
387
388    #[test]
389    fn schema_sdl_returns_schema() {
390        let (server, _ch, _tmp) = test_server();
391        let Json(resp) = server.schema_sdl();
392        let sdl = resp.result.as_str().unwrap();
393        assert!(sdl.contains("type QueryRoot"));
394        assert!(sdl.contains("type MutationRoot"));
395        assert!(sdl.contains("artists"));
396        assert!(sdl.contains("nowPlaying"));
397    }
398
399    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
400    async fn graphql_query_works() {
401        let (server, _ch, tmp) = test_server();
402        let db_path = tmp.path().join("test.db");
403        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
404
405        let result = server.graphql(
406            Parameters(GraphqlParams {
407                query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
408                variables: None,
409            }),
410            Default::default(),
411        );
412        assert!(result.is_ok());
413        let Json(resp) = result.unwrap();
414        let data = &resp.result["data"]["tracks"]["edges"];
415        assert_eq!(data.as_array().unwrap().len(), 1);
416        assert_eq!(data[0]["node"]["title"], "Windowlicker");
417    }
418
419    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
420    async fn graphql_mutation_works() {
421        let (server, _ch, _tmp) = test_server();
422        let result = server.graphql(
423            Parameters(GraphqlParams {
424                query: "mutation { pause { ok message } }".into(),
425                variables: None,
426            }),
427            Default::default(),
428        );
429        assert!(result.is_ok());
430        let Json(resp) = result.unwrap();
431        assert_eq!(resp.result["data"]["pause"]["ok"], true);
432    }
433
434    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
435    async fn graphql_now_playing_stopped() {
436        let (server, _ch, _tmp) = test_server();
437        let result = server.graphql(
438            Parameters(GraphqlParams {
439                query: "{ nowPlaying { state positionMs } }".into(),
440                variables: None,
441            }),
442            Default::default(),
443        );
444        assert!(result.is_ok());
445        let Json(resp) = result.unwrap();
446        assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
447    }
448
449    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
450    async fn graphql_library_stats() {
451        let (server, _ch, tmp) = test_server();
452        let db_path = tmp.path().join("test.db");
453        insert_test_track(&db_path, "T1", "A1", "Album1");
454
455        let result = server.graphql(
456            Parameters(GraphqlParams {
457                query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
458                variables: None,
459            }),
460            Default::default(),
461        );
462        assert!(result.is_ok());
463        let Json(resp) = result.unwrap();
464        assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
465    }
466}