use std::sync::Arc;
use crate::auth::AuthUser;
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,
}
#[derive(Clone)]
pub struct KoanMcpServer {
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
graphql_schema: crate::graphql::KoanSchema,
headless: bool,
public_url: Option<String>,
}
impl KoanMcpServer {
pub fn new(
state: Arc<SharedPlayerState>,
cmd_tx: Sender<PlayerCommand>,
pool: Arc<koan_core::db::pool::Pool>,
) -> Self {
let graphql_schema = crate::graphql::build_schema_extended(state, cmd_tx, pool, Denylist);
Self {
tool_router: Self::tool_router(),
graphql_schema,
headless: false,
public_url: None,
}
}
fn caller(&self, extensions: &rmcp::model::Extensions) -> AuthUser {
extensions
.get::<axum::http::request::Parts>()
.and_then(|p| p.extensions.get::<AuthUser>())
.map(|u| AuthUser {
role: capped(u.role),
..u.clone()
})
.unwrap_or_else(|| AuthUser {
user_id: koan_core::db::queries::LOCAL_USER,
role: mcp_role(),
..AuthUser::anonymous_admin()
})
}
}
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
}
}
pub const DENIED_MUTATIONS: &[&str] = &["organizeExecute", "organizeUndo", "updateConfig"];
struct Denylist;
impl async_graphql::extensions::ExtensionFactory for Denylist {
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
Arc::new(Denylist)
}
}
#[async_trait::async_trait]
impl async_graphql::extensions::Extension for Denylist {
async fn resolve(
&self,
ctx: &async_graphql::extensions::ExtensionContext<'_>,
info: async_graphql::extensions::ResolveInfo<'_>,
next: async_graphql::extensions::NextResolve<'_>,
) -> async_graphql::ServerResult<Option<async_graphql::Value>> {
if info.parent_type == "MutationRoot" && DENIED_MUTATIONS.contains(&info.name) {
return Err(async_graphql::ServerError::new(
format!("{} is not available through MCP", info.name),
None,
));
}
next.run(ctx, info).await
}
}
pub fn capped(role: koan_core::auth::Role) -> koan_core::auth::Role {
use koan_core::auth::Role;
match (role, mcp_role()) {
(Role::Admin, Role::User) => Role::User,
(role, _) => role,
}
}
#[tool_router]
impl KoanMcpServer {
#[tool(
description = "The GraphQL schema for the user's music (kōan): their library and the \
players they listen on. Call this first, before `graphql`. It covers playing, pausing, \
skipping and queueing music on the user's phone and computers, what is playing now, \
and searching, browsing and making playlists from the music they own."
)]
fn schema_sdl(&self) -> Json<GraphqlResponse> {
let sdl = self.graphql_schema.sdl();
Json(GraphqlResponse {
result: serde_json::Value::String(sdl),
})
}
#[tool(
description = "Control the user's music and search their music library (kōan). Use it \
for any request about music they listen to or own: play something, pause, resume, skip, \
what's playing, what's next, add to or change the queue, find or recommend from their \
collection, playlists, favourites. \"Pause the music on my desktop\", \"play some \
jazz on my phone\" and \"what is this song\" are all this tool.\n\n\
Call schema_sdl first for the full schema. The user's phones and computers running \
kōan are `clients`; commands for them end in `OnClient`.\n\n\
Examples:\n\
- What's playing, where: { clients { name playing nowPlaying positionMs } }\n\
- Pause: mutation { controlClient(action: PAUSE) { ok message } }\n\
- Find music: { tracks(search: \"aphex\", first: 20) { edges { node { id title artist album } } } }\n\
- Play it: mutation { playOnClient(trackIds: [\"42\", \"43\"]) { ok message } }\n\n\
String filters are case-insensitive substrings."
)]
async fn graphql(
&self,
Parameters(params): Parameters<GraphqlParams>,
extensions: rmcp::model::Extensions,
) -> Json<GraphqlResponse> {
let caller = self.caller(&extensions);
let result = crate::graphql::execute_in_process(
&self.graphql_schema,
¶ms.query,
params.variables,
caller,
)
.await;
Json(GraphqlResponse { result })
}
}
impl KoanMcpServer {
fn implementation(&self) -> rmcp::model::Implementation {
let info =
rmcp::model::Implementation::new("koan", env!("CARGO_PKG_VERSION")).with_title("kōan");
match self.public_url.as_deref().map(|u| u.trim_end_matches('/')) {
Some(base) => info.with_website_url(base).with_icons(vec![
rmcp::model::Icon::new(format!("{base}/ui/assets/icon-192.png"))
.with_mime_type("image/png")
.with_sizes(vec!["192x192".into()]),
]),
None => info,
}
}
}
#[rmcp::tool_handler]
impl ServerHandler for KoanMcpServer {
fn get_info(&self) -> ServerConfig {
let instructions = if self.headless {
SERVER_INSTRUCTIONS
} else {
LOCAL_INSTRUCTIONS
};
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(self.implementation())
.with_instructions(instructions)
}
}
const SERVER_INSTRUCTIONS: &str = "kōan is the user's music: their whole music library, and the \
phones and computers they listen on. Use it for anything about music they are playing or own — \
\"pause the music\", \"play something like Polar Bear on my phone\", \"what's this song\", \
\"skip to the Phace remix\", \"add their new album when it's downloaded\". Call `schema_sdl` \
once, then do everything through `graphql`.
## Where the music plays
The user listens in kōan apps on their devices, linked to this server. Query \
`clients { name platform playing nowPlaying album positionMs durationMs queue { trackId \
title artist current } }` to see each device, what it is playing and what it has queued. Every \
command about the user's music goes to a device:
- `controlClient(action: PAUSE|RESUME|NEXT|PREVIOUS)`, `seekOnClient(positionMs)`
- `playOnClient(trackIds, startAt)` replaces the queue and plays; `enqueue: true` appends. \
A phone iOS has suspended is not linked but is still reached. Music comes up there as a \
notification to tap, since iOS lets no app start audio on its own from sleep; queue and \
other changes are applied as it wakes. The message says when a device was asleep: tell the user \
to tap the notification
- `playNextOnClient(trackIds)`, `jumpOnClient(trackId)` (skip to a track, queued or not), \
`removeFromClient(trackIds)`, `clearClient`, `syncClient`
- **Making a playlist the user asked for** (\"make me a cyberpunk playlist\"): research what \
fits, find each track in the library, `createPlaylist` with those in order. For picks the \
library lacks, fetch the album with slsk's `grab`, then `addToPlaylistWhenAdded(playlistId, \
artist, album, titles)` to add the wanted tracks once it is imported. Tell the user what is \
there now and what is on its way.
- Playlists made or edited here (`createPlaylist`, `setPlaylistTracks`…) reach every device \
by themselves: linked ones sync at once, others when next opened. `syncClients` does the same \
on request.
- `evictOnClients(trackIds)` makes every linked device drop its downloaded copies of those \
tracks: when a track plays as noise or glitches, after the file on the server is replaced
- `queueOnClientWhenAdded(artist, album)` queues an album once it reaches the library, e.g. \
one being downloaded with slsk's `grab`; `clientOrders` lists those waiting
Leave `client` out unless the user named a device (\"my phone\", \"the desktop\": match it \
against `clients` names and platforms). Without it the server picks the device that is \
playing, else the one played most recently; if it answers that it cannot tell, ask the user \
which device.
**Act on what the user asks; do not second-guess it from reported state.** \"Pause\", \
\"skip\" and \"resume\" go straight to `controlClient`: the user can hear the device and you \
cannot, and a report can be stale or, from an older app (`playing: null`), absent.
**Never use the server's own player for the user's music.** `play`, `pause`, `resume`, \
`next`, `previous`, `seek`, `nowPlaying`, `queue`, `addToQueue`, `replaceQueue`, \
and `playPlaylist` drive a headless player on the server that nobody \
hears; `nowPlaying` there reports nothing about what the user is listening to.
## The library
- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
duration, favourites), `randomTracks`, `fuzzySearch`
- Build a set from these, then send its track ids to a device with `playOnClient`. Track ids are \
integers in queries; pass them to the client mutations as strings.
- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `addToPlaylist`, \
`setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`
- History: `playHistory`
- Sharing: `createShare(trackIds, description)` makes a public link anyone can open without an \
account; confirm with the user first. `shares`, `updateShare`, `deleteShare` manage them.
## Not available
`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
through MCP. Other admin mutations (`triggerScan`, user management) are refused unless \
`KOAN_MCP_ADMIN=1` is set.";
const LOCAL_INSTRUCTIONS: &str = "kōan is the user's music player on this machine and their \
music library. Use it for anything about music they are playing or own — \"pause the music\", \
\"play something like Polar Bear\", \"what's this song\". Call `schema_sdl` once, then do \
everything through `graphql`.
## Playback
This player is what the user hears: `play`, `pause`, `resume`, `stop`, `next`, `previous`, \
`seek`, `nowPlaying`; the queue with `queue`, `addToQueue`, `replaceQueue`, `removeFromQueue`, \
`moveInQueue`, `clearQueue`, `undo`, `redo`.
## The library
- `artists`, `albums`, `tracks` with filters (genre, year range, codec, sample rate, bit depth, \
duration, favourites), `randomTracks`, `fuzzySearch`
- Favourites: `favourite`, `unfavourite`, `toggleFavourite`, `favouritesOnly: true` on queries
- Playlists: `playlists`, `playlistTracks`, `createPlaylist`, `saveQueueAsPlaylist`, \
`addToPlaylist`, `setPlaylistTracks`, `renamePlaylist`, `deletePlaylist`, `playPlaylist`
- History: `playHistory`
- Sharing: `createShare(trackIds, description)` makes a public link; confirm with the user first.
## Not available
`organizeExecute`, `organizeUndo` (move files on disk) and `updateConfig` are never run \
through MCP. `triggerScan` and `setDevice` are refused unless `KOAN_MCP_ADMIN=1` is set.
## IDs
Track IDs are integers from the library; queue item IDs are UUIDs from the queue.";
const MAX_BODY: usize = 1024 * 1024;
const MAX_CONCURRENT: usize = 64;
pub fn router(
state: Arc<SharedPlayerState>,
cmd_tx: Sender<PlayerCommand>,
auth: crate::auth::middleware::AuthState,
public_url: Option<String>,
headless: bool,
shutdown: tokio_util::sync::CancellationToken,
) -> axum::Router {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
let mut template = KoanMcpServer::new(state, cmd_tx, auth.pool.clone());
template.headless = headless;
template.public_url = public_url.clone();
let service = StreamableHttpService::new(
move || Ok(template.clone()),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default()
.disable_allowed_hosts()
.with_cancellation_token(shutdown),
);
axum::Router::new()
.nest_service("/mcp", service)
.layer(tower_http::catch_panic::CatchPanicLayer::new())
.layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY))
.layer(axum::middleware::from_fn_with_state(
(auth, public_url),
bearer_gate,
))
.layer(
tower::ServiceBuilder::new()
.layer(axum::error_handling::HandleErrorLayer::new(
|_: tower::BoxError| async {
(axum::http::StatusCode::SERVICE_UNAVAILABLE, "busy")
},
))
.load_shed()
.concurrency_limit(MAX_CONCURRENT),
)
}
async fn bearer_gate(
axum::extract::State((auth, public_url)): axum::extract::State<(
crate::auth::middleware::AuthState,
Option<String>,
)>,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use axum::http::{Method, StatusCode, header};
use axum::response::IntoResponse;
let browser = req.method() == Method::GET
&& !req.headers().contains_key(header::AUTHORIZATION)
&& req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("text/html"));
if browser {
return axum::response::Redirect::to("/connect").into_response();
}
let user = if auth.auth_enabled {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|t| {
koan_core::auth::validate_scoped_token(
&auth.public_pem,
t,
Some(koan_core::auth::MCP_SCOPE),
)
.ok()
});
match token {
Some(claims) => crate::auth::current_user(&auth.pool, claims).await,
None => None,
}
} else {
Some(AuthUser::anonymous_admin())
};
match user {
Some(user) => {
req.extensions_mut().insert(user);
next.run(req).await
}
None => {
let challenge = match public_url.as_deref().map(|u| u.trim_end_matches('/')) {
Some(base) => format!(
"Bearer resource_metadata=\"{base}{}\"",
crate::ui::RESOURCE_METADATA
),
None => "Bearer".to_owned(),
};
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, challenge)],
"sign in to kōan",
)
.into_response()
}
}
}
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 pool = Arc::new(koan_core::db::pool::Pool::new(db_path));
let server = KoanMcpServer::new(state, cmd_tx, pool);
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, Arc::new(koan_core::db::pool::Pool::new(db_path)));
(server, ch, tmp)
}
fn as_user(user: AuthUser) -> rmcp::model::Extensions {
let (mut parts, ()) = axum::http::Request::new(()).into_parts();
parts.extensions.insert(user);
let mut ext = rmcp::model::Extensions::new();
ext.insert(parts);
ext
}
#[test]
fn a_token_acts_as_its_account_with_admin_capped() {
use koan_core::auth::Role;
let (server, _ch, _tmp) = test_server();
let user = |role| AuthUser {
user_id: 7,
username: "mate".into(),
role,
};
let c = server.caller(&as_user(user(Role::Admin)));
assert_eq!((c.user_id, c.username.as_str()), (7, "mate"));
assert_eq!(c.role, capped(Role::Admin));
assert_eq!(
server.caller(&as_user(user(Role::Readonly))).role,
Role::Readonly
);
let local = server.caller(&Default::default());
assert_eq!(
(local.user_id, local.role),
(queries::LOCAL_USER, mcp_role())
);
}
#[tokio::test]
async fn a_browser_opening_mcp_is_shown_how_to_connect() {
use tower::ServiceExt as _;
let (_server, ch, tmp) = test_server();
let auth = crate::auth::middleware::AuthState {
public_pem: Arc::new(Vec::new()),
auth_enabled: true,
introspection_key: None,
pool: Arc::new(koan_core::db::pool::Pool::new(tmp.path().join("test.db"))),
};
let app = router(
SharedPlayerState::new(),
ch.tx.clone(),
auth,
None,
true,
Default::default(),
);
let req = |accept: &str| {
axum::http::Request::get("/mcp")
.header(axum::http::header::ACCEPT, accept)
.body(axum::body::Body::empty())
.unwrap()
};
let r = app.clone().oneshot(req("text/html,*/*")).await.unwrap();
assert_eq!(r.headers()[axum::http::header::LOCATION], "/connect");
let r = app.oneshot(req("text/event-stream")).await.unwrap();
assert_eq!(r.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mutations_that_touch_files_are_refused_whoever_asks() {
use koan_core::auth::Role;
let (server, _ch, _tmp) = test_server();
let admin = as_user(AuthUser {
user_id: 1,
username: "owner".into(),
role: Role::Admin,
});
let Json(resp) = server
.graphql(
Parameters(GraphqlParams {
query: "mutation { undo: organizeUndo { ok } }".into(),
variables: None,
}),
admin,
)
.await;
let errors = resp.result["errors"].to_string();
assert!(errors.contains("not available through MCP"), "{errors}");
}
#[test]
fn a_server_with_an_address_names_its_icon() {
let (mut server, _ch, _tmp) = test_server();
assert!(server.get_info().server_info.icons.is_none());
server.public_url = Some("https://koan.test/".into());
let icons = server.get_info().server_info.icons.unwrap();
assert_eq!(icons[0].src, "https://koan.test/ui/assets/icon-192.png");
}
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(),
)
.await;
let Json(resp) = result;
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(),
)
.await;
let Json(resp) = result;
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(),
)
.await;
let Json(resp) = result;
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(),
)
.await;
let Json(resp) = result;
assert_eq!(resp.result["data"]["libraryStats"]["totalTracks"], 1);
}
}