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, ServerInfo};
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}
52
53impl KoanMcpServer {
54    pub fn new(
55        state: Arc<SharedPlayerState>,
56        cmd_tx: Sender<PlayerCommand>,
57        db_path: PathBuf,
58    ) -> Self {
59        let graphql_schema =
60            crate::graphql::build_schema(state.clone(), cmd_tx.clone(), db_path.clone(), None);
61        Self {
62            tool_router: Self::tool_router(),
63            graphql_schema,
64        }
65    }
66}
67
68use rmcp::handler::server::wrapper::Parameters;
69use rmcp::tool;
70
71/// Role the MCP `graphql` tool executes at.
72///
73/// The transport carries no credential, so anything reachable here is reachable
74/// by whoever can talk to the MCP process. `User` covers everything the tool
75/// advertises — browsing, playback, queue, favourites, playlists, radio — and
76/// leaves out the admin mutations that move files on disk (`organize*`), rewrite
77/// config, or change the output device. `KOAN_MCP_ADMIN=1` opts back in.
78fn mcp_role() -> koan_core::auth::Role {
79    if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
80        koan_core::auth::Role::Admin
81    } else {
82        koan_core::auth::Role::User
83    }
84}
85
86#[tool_router]
87impl KoanMcpServer {
88    #[tool(
89        description = "Get the full GraphQL schema in SDL format. CALL THIS FIRST to learn all \
90        available queries, mutations, types, and filter parameters. The schema is the complete \
91        reference for everything koan can do — library discovery, playback control, queue \
92        management, favourites, playlists, radio mode, device switching, and more."
93    )]
94    fn schema_sdl(&self) -> Json<GraphqlResponse> {
95        let sdl = self.graphql_schema.sdl();
96        Json(GraphqlResponse {
97            result: serde_json::Value::String(sdl),
98        })
99    }
100
101    #[tool(
102        description = "Execute a GraphQL query or mutation against the koan music player. \
103        This is the primary interface for ALL operations — library browsing, playback control, \
104        queue management, favourites, playlists, radio, devices.\n\n\
105        Call schema_sdl first to learn the full schema.\n\n\
106        Quick examples:\n\
107        - Search: { tracks(search: \"aphex\") { edges { node { id title artist album } } } }\n\
108        - Filter: { albums(yearEnd: 1995, codec: \"FLAC\") { edges { node { title artistName date } } } }\n\
109        - Now playing: { nowPlaying { state positionMs track { title artist codec sampleRate } } }\n\
110        - Queue tracks: mutation { addToQueue(trackIds: [42, 43]) { ok addedCount } }\n\
111        - Play/pause: mutation { pause { ok } } / mutation { resume { ok } }\n\
112        - Playlist: mutation { saveQueueAsPlaylist(name: \"techno\") { id name } }\n\
113        - Radio: mutation { enableRadio { ok } }\n\n\
114        Track IDs are integers from the library. Queue item IDs are UUIDs from the queue.\n\
115        All string filters are case-insensitive substrings."
116    )]
117    fn graphql(
118        &self,
119        Parameters(params): Parameters<GraphqlParams>,
120    ) -> Result<Json<GraphqlResponse>, String> {
121        let schema = self.graphql_schema.clone();
122        let query = params.query;
123        let variables = params.variables;
124        let rt =
125            tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
126        let result = tokio::task::block_in_place(|| {
127            rt.block_on(crate::graphql::execute_in_process(
128                &schema,
129                &query,
130                variables,
131                mcp_role(),
132            ))
133        });
134        Ok(Json(GraphqlResponse { result }))
135    }
136}
137
138#[rmcp::tool_handler]
139impl ServerHandler for KoanMcpServer {
140    fn get_info(&self) -> ServerInfo {
141        ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
142            "koan is a bit-perfect macOS music player. You control it entirely via GraphQL.\n\n\
143             ## How to use\n\
144             1. Call `schema_sdl` to get the full GraphQL schema\n\
145             2. Use the `graphql` tool for ALL queries and mutations\n\n\
146             ## What you can do\n\
147             - **Discover music**: query `artists`, `albums`, `tracks` with rich filters \
148               (genre, year range, codec, sample rate, bit depth, duration, favourites)\n\
149             - **Control playback**: mutations `play`, `pause`, `resume`, `stop`, `next`, \
150               `previous`, `seek`\n\
151             - **Manage queue**: `addToQueue`, `replaceQueue`, `removeFromQueue`, `moveInQueue`, \
152               `clearQueue`, `undo`, `redo`\n\
153             - **Favourites**: `favourite`, `unfavourite`, `toggleFavourite` (auto-syncs to \
154               Subsonic/Navidrome). Filter any query with `favouritesOnly: true`\n\
155             - **Playlists**: query `playlists`/`playlistTracks`; `createPlaylist`, \
156               `saveQueueAsPlaylist`, `addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, \
157               `deletePlaylist`, `playPlaylist`. Synced to Subsonic/Navidrome\n\
158             - **Radio**: `enableRadio`, `disableRadio` — auto-queues similar tracks\n\
159             - **Devices**: query `devices`; `setDevice`/`clearDevice` need `KOAN_MCP_ADMIN=1`\n\
160             - **History**: query `playHistory`, `similarArtists`\n\n\
161             ## Not available\n\
162             Admin mutations — `organize*` (moves files on disk), `updateConfig`, \
163             `triggerScan`, `createShare` — are refused unless `KOAN_MCP_ADMIN=1` is set.\n\n\
164             ## ID conventions\n\
165             - Track IDs: integers from the library database\n\
166             - Queue item IDs: UUIDs assigned when tracks enter the queue",
167        )
168    }
169}
170
171/// Entry point for `koan mcp` — starts a headless player with an MCP server on stdio.
172pub fn cmd_mcp() {
173    use koan_core::player::Player;
174    use rmcp::ServiceExt;
175
176    // Validate DB is accessible before starting the server.
177    let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
178    let db_path = koan_core::config::db_path();
179
180    // Spawn the player engine (headless — no TUI).
181    let (state, _timeline, _viz, cmd_tx) = Player::spawn();
182
183    let server = KoanMcpServer::new(state, cmd_tx, db_path);
184
185    // Run the MCP server on the tokio runtime (blocking the main thread).
186    let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
187    rt.block_on(async {
188        let transport = rmcp::transport::io::stdio();
189        let service = server
190            .serve(transport)
191            .await
192            .expect("failed to start MCP server");
193        let _ = service.waiting().await;
194    });
195}
196
197// ---------------------------------------------------------------------------
198// Tests
199// ---------------------------------------------------------------------------
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use koan_core::db::connection::Database;
205    use koan_core::db::queries;
206    use koan_core::player::commands::CommandChannel;
207    use tempfile::TempDir;
208
209    fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
210        let tmp = TempDir::new().unwrap();
211        let db_path = tmp.path().join("test.db");
212        let db = Database::open(&db_path).unwrap();
213        koan_core::db::schema::create_tables(&db.conn).unwrap();
214
215        let state = SharedPlayerState::new();
216        let ch = CommandChannel::new();
217        let tx = ch.tx.clone();
218
219        let server = KoanMcpServer::new(state, tx, db_path);
220        (server, ch, tmp)
221    }
222
223    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
224        let db = Database::open(db_path).unwrap();
225        let meta = queries::TrackMeta {
226            title: title.to_string(),
227            artist: artist.to_string(),
228            album_artist: Some(artist.to_string()),
229            album: album.to_string(),
230            track_number: Some(1),
231            disc: Some(1),
232            date: Some("2024".into()),
233            genre: Some("Electronic".into()),
234            duration_ms: Some(240000),
235            path: Some(format!(
236                "/tmp/test/{}.flac",
237                title.to_lowercase().replace(' ', "_")
238            )),
239            codec: Some("FLAC".into()),
240            sample_rate: Some(44100),
241            bit_depth: Some(16),
242            channels: Some(2),
243            bitrate: Some(1411),
244            size_bytes: Some(42_000_000),
245            mtime: Some(1700000000),
246            source: "local".into(),
247            remote_id: None,
248            remote_url: None,
249            album_remote_id: None,
250            artist_remote_id: None,
251            mbid: None,
252            album_added_at: None,
253            label: None,
254        };
255        queries::upsert_track(&db.conn, &meta).unwrap()
256    }
257
258    #[test]
259    fn schema_sdl_returns_schema() {
260        let (server, _ch, _tmp) = test_server();
261        let Json(resp) = server.schema_sdl();
262        let sdl = resp.result.as_str().unwrap();
263        assert!(sdl.contains("type QueryRoot"));
264        assert!(sdl.contains("type MutationRoot"));
265        assert!(sdl.contains("artists"));
266        assert!(sdl.contains("nowPlaying"));
267    }
268
269    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
270    async fn graphql_query_works() {
271        let (server, _ch, tmp) = test_server();
272        let db_path = tmp.path().join("test.db");
273        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
274
275        let result = server.graphql(Parameters(GraphqlParams {
276            query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
277            variables: None,
278        }));
279        assert!(result.is_ok());
280        let Json(resp) = result.unwrap();
281        let data = &resp.result["data"]["tracks"]["edges"];
282        assert_eq!(data.as_array().unwrap().len(), 1);
283        assert_eq!(data[0]["node"]["title"], "Windowlicker");
284    }
285
286    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
287    async fn graphql_mutation_works() {
288        let (server, _ch, _tmp) = test_server();
289        let result = server.graphql(Parameters(GraphqlParams {
290            query: "mutation { pause { ok message } }".into(),
291            variables: None,
292        }));
293        assert!(result.is_ok());
294        let Json(resp) = result.unwrap();
295        assert_eq!(resp.result["data"]["pause"]["ok"], true);
296    }
297
298    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
299    async fn graphql_now_playing_stopped() {
300        let (server, _ch, _tmp) = test_server();
301        let result = server.graphql(Parameters(GraphqlParams {
302            query: "{ nowPlaying { state positionMs } }".into(),
303            variables: None,
304        }));
305        assert!(result.is_ok());
306        let Json(resp) = result.unwrap();
307        assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
308    }
309
310    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
311    async fn graphql_library_stats() {
312        let (server, _ch, tmp) = test_server();
313        let db_path = tmp.path().join("test.db");
314        insert_test_track(&db_path, "T1", "A1", "Album1");
315
316        let result = server.graphql(Parameters(GraphqlParams {
317            query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
318            variables: None,
319        }));
320        assert!(result.is_ok());
321        let Json(resp) = result.unwrap();
322        assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
323    }
324}