use std::path::PathBuf;
use std::sync::Arc;
use crossbeam_channel::Sender;
use koan_core::player::commands::PlayerCommand;
use koan_core::player::state::SharedPlayerState;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Json;
use rmcp::model::{ServerCapabilities, ServerConfig};
use rmcp::{ServerHandler, schemars, tool_router};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GraphqlParams {
#[schemars(
description = "GraphQL query or mutation string. Use the schema_sdl tool first to learn available types, queries, mutations, and filter parameters."
)]
pub query: String,
#[schemars(description = "Optional JSON object of query variables")]
pub variables: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct GraphqlResponse {
pub result: serde_json::Value,
}
pub const USERNAME_HEADER: &str = "x-koan-username";
pub const PASSWORD_HEADER: &str = "x-koan-password";
#[derive(Clone)]
pub struct KoanMcpServer {
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
graphql_schema: crate::graphql::KoanSchema,
users: Option<Arc<crate::auth::password::PasswordVerifier>>,
}
impl KoanMcpServer {
pub fn new(
state: Arc<SharedPlayerState>,
cmd_tx: Sender<PlayerCommand>,
db_path: PathBuf,
) -> Self {
let graphql_schema =
crate::graphql::build_schema(state.clone(), cmd_tx.clone(), db_path.clone(), None);
Self {
tool_router: Self::tool_router(),
graphql_schema,
users: None,
}
}
fn role(&self, extensions: &rmcp::model::Extensions) -> Result<koan_core::auth::Role, String> {
let Some(users) = &self.users else {
return Ok(mcp_role());
};
let parts = extensions.get::<axum::http::request::Parts>();
let get = |h: &str| {
parts
.and_then(|p| p.headers.get(h))
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
};
match (get(USERNAME_HEADER), get(PASSWORD_HEADER)) {
(Some(u), Some(p)) => users
.verify(u, p)
.ok_or_else(|| "koan rejected that username and password".to_string()),
_ if std::env::var("KOAN_MCP_REQUIRE_LOGIN").is_ok_and(|v| v == "1") => Err(format!(
"this koan needs an account: send {USERNAME_HEADER} and {PASSWORD_HEADER}"
)),
_ => Ok(mcp_role()),
}
}
}
use rmcp::handler::server::wrapper::Parameters;
use rmcp::tool;
fn mcp_role() -> koan_core::auth::Role {
if std::env::var("KOAN_MCP_ADMIN").is_ok_and(|v| v == "1") {
koan_core::auth::Role::Admin
} else {
koan_core::auth::Role::User
}
}
#[tool_router]
impl KoanMcpServer {
#[tool(
description = "Get the full GraphQL schema in SDL format. CALL THIS FIRST to learn all \
available queries, mutations, types, and filter parameters. The schema is the complete \
reference for everything koan can do — library discovery, playback control, queue \
management, favourites, playlists, radio mode, device switching, and more."
)]
fn schema_sdl(&self) -> Json<GraphqlResponse> {
let sdl = self.graphql_schema.sdl();
Json(GraphqlResponse {
result: serde_json::Value::String(sdl),
})
}
#[tool(
description = "Execute a GraphQL query or mutation against the koan music player. \
This is the primary interface for ALL operations — library browsing, playback control, \
queue management, favourites, playlists, radio, devices.\n\n\
Call schema_sdl first to learn the full schema.\n\n\
Quick examples:\n\
- Search: { tracks(search: \"aphex\") { edges { node { id title artist album } } } }\n\
- Filter: { albums(yearEnd: 1995, codec: \"FLAC\") { edges { node { title artistName date } } } }\n\
- Now playing: { nowPlaying { state positionMs track { title artist codec sampleRate } } }\n\
- Queue tracks: mutation { addToQueue(trackIds: [42, 43]) { ok addedCount } }\n\
- Play/pause: mutation { pause { ok } } / mutation { resume { ok } }\n\
- Playlist: mutation { saveQueueAsPlaylist(name: \"techno\") { id name } }\n\
- Radio: mutation { enableRadio { ok } }\n\n\
Track IDs are integers from the library. Queue item IDs are UUIDs from the queue.\n\
All string filters are case-insensitive substrings."
)]
fn graphql(
&self,
Parameters(params): Parameters<GraphqlParams>,
extensions: rmcp::model::Extensions,
) -> Result<Json<GraphqlResponse>, String> {
let schema = self.graphql_schema.clone();
let query = params.query;
let variables = params.variables;
let rt =
tokio::runtime::Handle::try_current().map_err(|_| "no tokio runtime".to_string())?;
let result = tokio::task::block_in_place(|| {
let role = self.role(&extensions)?;
Ok::<_, String>(rt.block_on(crate::graphql::execute_in_process(
&schema, &query, variables, role,
)))
})?;
Ok(Json(GraphqlResponse { result }))
}
}
#[rmcp::tool_handler]
impl ServerHandler for KoanMcpServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"koan",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"koan is a bit-perfect music player. You control it entirely via GraphQL.\n\n\
## How to use\n\
1. Call `schema_sdl` to get the full GraphQL schema\n\
2. Use the `graphql` tool for ALL queries and mutations\n\n\
## What you can do\n\
- **Discover music**: query `artists`, `albums`, `tracks` with rich filters \
(genre, year range, codec, sample rate, bit depth, duration, favourites)\n\
- **Control playback**: mutations `play`, `pause`, `resume`, `stop`, `next`, \
`previous`, `seek`\n\
- **Manage queue**: `addToQueue`, `replaceQueue`, `removeFromQueue`, `moveInQueue`, \
`clearQueue`, `undo`, `redo`\n\
- **Favourites**: `favourite`, `unfavourite`, `toggleFavourite` (auto-syncs to \
Subsonic/Navidrome). Filter any query with `favouritesOnly: true`\n\
- **Playlists**: query `playlists`/`playlistTracks`; `createPlaylist`, \
`saveQueueAsPlaylist`, `addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, \
`deletePlaylist`, `playPlaylist`. Synced to Subsonic/Navidrome\n\
- **Radio**: `enableRadio`, `disableRadio` — auto-queues similar tracks\n\
- **Devices**: query `devices`; `setDevice`/`clearDevice` need `KOAN_MCP_ADMIN=1`\n\
- **History**: query `playHistory`, `similarArtists`\n\
- **Sharing**: `createShare(trackIds, description)` returns a public link anyone can \
open without an account; query `shares` to list them, `updateShare` to set an \
expiry, `deleteShare` to revoke one. A link is public, so confirm with the user \
before making one\n\n\
## Not available\n\
Admin mutations — `organize*` (moves files on disk), `updateConfig`, \
`triggerScan` — are refused unless `KOAN_MCP_ADMIN=1` is set.\n\n\
## ID conventions\n\
- Track IDs: integers from the library database\n\
- Queue item IDs: UUIDs assigned when tracks enter the queue",
)
}
}
pub fn spawn_http(
addr: std::net::SocketAddr,
state: Arc<SharedPlayerState>,
cmd_tx: Sender<PlayerCommand>,
db_path: PathBuf,
) -> std::io::Result<std::thread::JoinHandle<()>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
let mut template = KoanMcpServer::new(state, cmd_tx, db_path.clone());
template.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
db_path,
)));
let listener = std::net::TcpListener::bind(addr)?;
listener.set_nonblocking(true)?;
std::thread::Builder::new()
.name("koan-mcp-http".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to create tokio runtime");
rt.block_on(async move {
let service = StreamableHttpService::new(
move || Ok(template.clone()),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default().disable_allowed_hosts(),
);
let app = axum::Router::new().nest_service("/mcp", service);
let listener =
tokio::net::TcpListener::from_std(listener).expect("listener from std");
if let Err(e) = axum::serve(listener, app).await {
log::error!("MCP HTTP server stopped: {e}");
}
});
})
}
pub fn cmd_mcp() {
use koan_core::player::Player;
use rmcp::ServiceExt;
let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
let db_path = koan_core::config::db_path();
let (state, _timeline, _viz, cmd_tx) = Player::spawn();
let server = KoanMcpServer::new(state, cmd_tx, db_path);
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
rt.block_on(async {
let transport = rmcp::transport::io::stdio();
let service = server
.serve(transport)
.await
.expect("failed to start MCP server");
let _ = service.waiting().await;
});
}
#[cfg(test)]
mod tests {
use super::*;
use koan_core::db::connection::Database;
use koan_core::db::queries;
use koan_core::player::commands::CommandChannel;
use tempfile::TempDir;
fn test_server() -> (KoanMcpServer, CommandChannel, TempDir) {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("test.db");
let db = Database::open(&db_path).unwrap();
koan_core::db::schema::create_tables(&db.conn).unwrap();
let state = SharedPlayerState::new();
let ch = CommandChannel::new();
let tx = ch.tx.clone();
let server = KoanMcpServer::new(state, tx, db_path);
(server, ch, tmp)
}
fn with_headers(headers: &[(&str, &str)]) -> rmcp::model::Extensions {
let mut req = axum::http::Request::builder();
for (k, v) in headers {
req = req.header(*k, *v);
}
let (parts, ()) = req.body(()).unwrap().into_parts();
let mut ext = rmcp::model::Extensions::new();
ext.insert(parts);
ext
}
#[test]
fn gateway_headers_act_as_that_account() {
use koan_core::auth::Role;
let (mut server, _ch, tmp) = test_server();
let db_path = tmp.path().join("test.db");
let db = Database::open(&db_path).unwrap();
queries::auth::create_user(&db.conn, "owner", "sesame", Role::Admin).unwrap();
queries::auth::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
server.users = Some(Arc::new(crate::auth::password::PasswordVerifier::new(
db_path,
)));
let as_ = |u: &str, p: &str| {
server.role(&with_headers(&[(USERNAME_HEADER, u), (PASSWORD_HEADER, p)]))
};
assert_eq!(as_("owner", "sesame"), Ok(Role::Admin));
assert_eq!(as_("mate", "hunter22"), Ok(Role::Readonly));
assert!(as_("owner", "wrong").is_err());
assert_eq!(server.role(&with_headers(&[])), Ok(mcp_role()));
}
fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
let db = Database::open(db_path).unwrap();
let meta = queries::TrackMeta {
title: title.to_string(),
artist: artist.to_string(),
album_artist: Some(artist.to_string()),
album: album.to_string(),
track_number: Some(1),
disc: Some(1),
date: Some("2024".into()),
genre: Some("Electronic".into()),
duration_ms: Some(240000),
path: Some(format!(
"/tmp/test/{}.flac",
title.to_lowercase().replace(' ', "_")
)),
codec: Some("FLAC".into()),
sample_rate: Some(44100),
bit_depth: Some(16),
channels: Some(2),
bitrate: Some(1411),
size_bytes: Some(42_000_000),
mtime: Some(1700000000),
source: "local".into(),
remote_id: None,
remote_url: None,
album_remote_id: None,
artist_remote_id: None,
mbid: None,
album_mbid: None,
album_added_at: None,
label: None,
};
queries::upsert_track(&db.conn, &meta).unwrap()
}
#[test]
fn schema_sdl_returns_schema() {
let (server, _ch, _tmp) = test_server();
let Json(resp) = server.schema_sdl();
let sdl = resp.result.as_str().unwrap();
assert!(sdl.contains("type QueryRoot"));
assert!(sdl.contains("type MutationRoot"));
assert!(sdl.contains("artists"));
assert!(sdl.contains("nowPlaying"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_query_works() {
let (server, _ch, tmp) = test_server();
let db_path = tmp.path().join("test.db");
insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
let result = server.graphql(
Parameters(GraphqlParams {
query: r#"{ tracks(search: "aphex") { edges { node { title artist } } } }"#.into(),
variables: None,
}),
Default::default(),
);
assert!(result.is_ok());
let Json(resp) = result.unwrap();
let data = &resp.result["data"]["tracks"]["edges"];
assert_eq!(data.as_array().unwrap().len(), 1);
assert_eq!(data[0]["node"]["title"], "Windowlicker");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_mutation_works() {
let (server, _ch, _tmp) = test_server();
let result = server.graphql(
Parameters(GraphqlParams {
query: "mutation { pause { ok message } }".into(),
variables: None,
}),
Default::default(),
);
assert!(result.is_ok());
let Json(resp) = result.unwrap();
assert_eq!(resp.result["data"]["pause"]["ok"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_now_playing_stopped() {
let (server, _ch, _tmp) = test_server();
let result = server.graphql(
Parameters(GraphqlParams {
query: "{ nowPlaying { state positionMs } }".into(),
variables: None,
}),
Default::default(),
);
assert!(result.is_ok());
let Json(resp) = result.unwrap();
assert_eq!(resp.result["data"]["nowPlaying"]["state"], "STOPPED");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_library_stats() {
let (server, _ch, tmp) = test_server();
let db_path = tmp.path().join("test.db");
insert_test_track(&db_path, "T1", "A1", "Album1");
let result = server.graphql(
Parameters(GraphqlParams {
query: "{ libraryStats { totalTracks totalArtists totalAlbums } }".into(),
variables: None,
}),
Default::default(),
);
assert!(result.is_ok());
let Json(resp) = result.unwrap();
assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
}
}