diff --git a/packages/docs/SUMMARY.md b/packages/docs/SUMMARY.md index 896cfd86..610d80d6 100644 --- a/packages/docs/SUMMARY.md +++ b/packages/docs/SUMMARY.md @@ -12,6 +12,10 @@ * [Dashboard](plugins/dashboard.md) * [Providers](plugins/providers.md) +## Integrations + +* [MCP Server](integrations/mcp-server.md) + ## Theming * [Themes](themes/themes.md) @@ -21,6 +25,7 @@ ## Development * [Logging](development/logging.md) +* [MCP Architecture](development/mcp-architecture.md) ## Misc diff --git a/packages/docs/development/mcp-architecture.md b/packages/docs/development/mcp-architecture.md new file mode 100644 index 00000000..1c38dad3 --- /dev/null +++ b/packages/docs/development/mcp-architecture.md @@ -0,0 +1,93 @@ +--- +description: How the MCP server works internally and how to extend it. +--- + +# MCP architecture + +Nuclear's [MCP](https://modelcontextprotocol.io/) server lets your AI control the music player and do anything that plugins can do! + +The server runs on `localhost:8800/mcp` using the Streamable HTTP transport. + +## The four MCP tools + +Rust defines four tools in `tools.rs`. No business logic lives in Rust. These tools reuse the same API as plugins. + +| Tool | Purpose | Arguments | +|------|---------|-----------| +| `list_methods` | List methods in a domain | `{ domain: "Queue" }` | +| `method_details` | Get parameter names, types, return type for a method | `{ method: "Queue.addToQueue" }` | +| `describe_type` | Get the JSON shape of a data type (Track, QueueItem, etc.) | `{ type: "Track" }` | +| `call` | Execute a method | `{ method: "Queue.addToQueue", params: { tracks: [...] } }` | + +The first three are discovery tools. Agents use them to figure out what's available before calling anything. The TS handler serves these from static metadata objects (`apiMeta` and `typeRegistry` from `@nuclearplayer/plugin-sdk/mcp`). + +The `call` tool runs through the dispatcher, which converts named parameters to positional arguments and calls the API method. + +## The Rust/JS bridge protocol + +Rust and JS communicate through Tauri's event system with a request/response pattern correlated by trace IDs. + +### Request flow + +1. An agent calls a tool. Rust receives the HTTP request. +2. Rust generates a UUID trace ID, stores a `oneshot::Sender` in a `HashMap`, and emits an `mcp:tool-call` event to the webview with `{ traceId, toolName, arguments }`. +3. JS receives the event, validates the payload with Zod, and routes to the appropriate handler. +4. For `list_methods`, `method_details`, and `describe_type`: JS looks up the answer in `apiMeta` or `typeRegistry`. No API call happens. +5. For `call`: the dispatcher parses `"Queue.addToQueue"` into domain + method, looks up the `MethodMeta` to get the parameter order, converts the named params object into positional args, and calls the method on `NuclearPluginAPI`. +6. JS calls `invoke('mcp_respond', { response: { traceId, success, data } })` (or `{ traceId, success: false, error }` on failure). +7. Rust receives the `mcp_respond` command, looks up the trace ID in the pending map, and sends the response through the oneshot channel. The original `call_tool` function was awaiting this channel and now returns the result to the HTTP response. + +### Timeout and error handling + +The bridge times out after 30 seconds. If JS doesn't respond in that window, Rust removes the pending entry and returns an error. + +There are two error types: + +- `InfrastructureError`: The bridge itself broke. Timeout, channel closed, event emission failed. Rust logs the error and returns it as an MCP protocol error (internal error). This means something is wrong with the bridge, not with the requested operation. +- `ToolError`: The method ran but returned an error (e.g., "unknown domain", "playlist not found"). Rust passes this through as tool error content, which the agent can read and react to. + +## Server lifecycle + +`mcp_start` and `mcp_stop` are Tauri commands (not events), so the JS caller gets a `Result` back and can handle errors. + +### Startup + +MCP tries to bind to `localhost:8800` by default, but if that port is taken, it tries the next one up, up to 8809. If all are taken, it returns an error. + +### Settings + +The server can be disabled or enabled in the settings, and shows you its URL. + +### Metadata types + +Defined in `meta.ts`: + +```typescript +type ParamMeta = { + name: string; + type: string; +}; + +type MethodMeta = { + name: string; + description: string; + params: ParamMeta[]; + returns: string; +}; + +type DomainMeta = { + description: string; + methods: Record; +}; +``` + +Each domain has a `*.meta.ts` file (e.g., `queue.meta.ts`) that exports a `DomainMeta` object. The `apiMeta` object in `meta.ts` aggregates all domain metadata by importing these files. + +The `typeRegistry` in `typeRegistry.ts` maps type names (like `"Track"`, `"QueueItem"`) to their field definitions so agents can call `describe_type` and understand the data shapes. + +## How to add a new domain + +1. Create the Host interface, host implementation, and API class following the standard plugin SDK pattern (see [adding new domains](../plugin-sdk/adding-domains.md)). +2. Create a `yourdomain.meta.ts` file in `packages/plugin-sdk/src/mcp/` exporting a `DomainMeta`. +3. Import and register it in the `apiMeta` object in `packages/plugin-sdk/src/mcp/meta.ts`. +4. Update the domain list in the `list_methods` tool description string in `packages/player/src-tauri/src/mcp/tools.rs`. This is the only Rust change needed when adding a domain. \ No newline at end of file diff --git a/packages/docs/integrations/mcp-server.md b/packages/docs/integrations/mcp-server.md new file mode 100644 index 00000000..eda365b5 --- /dev/null +++ b/packages/docs/integrations/mcp-server.md @@ -0,0 +1,137 @@ +--- +description: Let AI agents control Nuclear via the Model Context Protocol. +--- + +# MCP server + +Nuclear includes a built-in [MCP](https://modelcontextprotocol.io/) server that lets your AI control the music player, doing pretty much anything that you can! + +## Enable the server + +1. Open Nuclear → Settings → Integrations. +2. Toggle `Enable MCP Server` on. +3. The server starts on `http://127.0.0.1:8800/mcp` (localhost only). If port 8800 is taken, it tries 8801, 8802, and so on up to 8809. +4. The **MCP Server URL** field below the toggle shows the actual URL. Click the copy button to grab it. + +## Connect your AI tool + +The server URL is `http://127.0.0.1:8800/mcp` using the `Streamable HTTP` transport. + +{% tabs %} +{% tab title="Claude Code" %} +```bash +claude mcp add nuclear --transport http http://127.0.0.1:8800/mcp +``` +{% endtab %} + +{% tab title="OpenCode" %} +Add to your `opencode.json`: + +```json +{ + "mcp": { + "nuclear": { + "type": "remote", + "url": "http://127.0.0.1:8800/mcp" + } + } +} +``` +{% endtab %} + +{% tab title="Codex CLI" %} +Add to `~/.codex/config.toml`: + +```toml +[mcp_servers.nuclear] +url = "http://127.0.0.1:8800/mcp" +``` + +Or via the CLI: + +```bash +codex mcp add nuclear --url http://127.0.0.1:8800/mcp +``` +{% endtab %} + +{% tab title="Claude Desktop / Cursor / Windsurf" %} +Add to your MCP config (`claude_desktop_config.json`, `.cursor/mcp.json`, etc.): + +```json +{ + "mcpServers": { + "nuclear": { + "url": "http://127.0.0.1:8800/mcp" + } + } +} +``` +{% endtab %} + +{% tab title="MCP Inspector" %} +```bash +npx @modelcontextprotocol/inspector +``` + +Enter `http://127.0.0.1:8800/mcp` as the URL and select `Streamable HTTP` as the transport. +{% endtab %} +{% endtabs %} + +## Tools + +Nuclear exposes four MCP tools. The server uses a hierarchical discovery pattern: start broad, drill down, then act. + +### `list_methods` + +Lists available methods in a domain. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `domain` | string | yes | One of: `Queue`, `Playback`, `Metadata`, `Favorites`, `Playlists`, `Dashboard`, `Providers`. | + +Returns the method names and short descriptions for that domain. + +### `method_details` + +Gets full details for a single method: its description, parameter names and types, and return type. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------------------------------- | +| `method` | string | yes | The method name in `Domain.method` format, e.g. `Queue.addToQueue`. | + +### `describe_type` + +Gets the JSON shape of a data type. Use this when `method_details` returns a parameter or return type that references a complex type. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------- | +| `type` | string | yes | The type name, e.g. `Track`, `QueueItem`, `Playlist`. | + +### `call` + +Calls a Nuclear API method. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------------------------------------- | +| `method` | string | yes | The method name in `Domain.method` format, e.g. `Queue.addToQueue`. | +| `params` | object | no | A JSON object with named fields matching the method's parameters. Omit or pass `{}` for methods with no parameters. | + +## Discovery workflow + +An agent follows this sequence to find and call an API method: + +1. Read the `list_methods` tool description to see the seven available domains. +2. Call `list_methods` with a domain (e.g. `Queue`) to see that domain's methods. +3. Call `method_details` (e.g. `Queue.addToQueue`) to get parameter names, types, and the return type. +4. If a parameter or return type is a complex type like `Track`, call `describe_type` to see its fields. +5. Call `call` with the method name and parameters to execute it. + +Each step returns a small, focused payload to save on tokens. + +## Agent skill + +If your AI tool supports skills (like Claude Code), you can install one that teaches the agent how to use Nuclear's MCP tools, including the discovery workflow, common recipes, and the full API reference. + +[Download nuclear-mcp.zip](/skills/nuclear-mcp.zip) + +Unzip it into your skills directory (e.g. `~/.claude/skills/`) and the agent will pick it up automatically. \ No newline at end of file diff --git a/packages/docs/public/skills/nuclear-mcp.zip b/packages/docs/public/skills/nuclear-mcp.zip new file mode 100644 index 00000000..d55509a7 Binary files /dev/null and b/packages/docs/public/skills/nuclear-mcp.zip differ diff --git a/packages/i18n/src/locales/en_US.json b/packages/i18n/src/locales/en_US.json index 214b48d0..6c3cbdcf 100644 --- a/packages/i18n/src/locales/en_US.json +++ b/packages/i18n/src/locales/en_US.json @@ -253,6 +253,19 @@ "title": "Auto-install updates", "description": "Download and install updates automatically when available" } + }, + "integrations": { + "title": "Integrations", + "mcp": { + "enabled": { + "title": "Enable MCP Server", + "description": "Start a local MCP server that allows AI tools to control Nuclear." + }, + "serverUrl": { + "title": "MCP Server URL", + "description": "Point your AI tool to this URL to connect to Nuclear." + } + } } }, "queue": { diff --git a/packages/player/src-tauri/Cargo.lock b/packages/player/src-tauri/Cargo.lock index ef590b58..38ef313f 100644 --- a/packages/player/src-tauri/Cargo.lock +++ b/packages/player/src-tauri/Cargo.lock @@ -25,7 +25,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -236,7 +236,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -265,13 +265,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -309,6 +309,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -423,7 +469,7 @@ dependencies = [ "proc-macro-crate 2.0.2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "syn_derive", ] @@ -650,6 +696,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.41" @@ -794,6 +851,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -867,7 +933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -877,7 +943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -886,8 +952,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -901,7 +977,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -910,9 +999,20 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -950,7 +1050,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -963,7 +1063,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1022,7 +1122,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1054,7 +1154,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1167,7 +1267,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1308,6 +1408,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1335,7 +1441,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1459,7 +1565,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1648,6 +1754,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1720,7 +1840,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1799,7 +1919,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1835,6 +1955,9 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -1930,6 +2053,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.6.0" @@ -1943,6 +2072,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2129,6 +2259,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2404,6 +2540,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2547,7 +2689,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -2556,6 +2698,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.5" @@ -2628,7 +2776,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -2810,7 +2958,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3162,7 +3310,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3267,6 +3415,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3393,7 +3547,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3456,6 +3610,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "player" version = "1.1.0" dependencies = [ + "axum", "base64 0.22.1", "chrono", "fix-path-env", @@ -3466,6 +3621,8 @@ dependencies = [ "once_cell", "percent-encoding", "reqwest 0.12.22", + "rmcp", + "schemars 1.0.4", "serde", "serde_json", "tauri", @@ -3479,6 +3636,8 @@ dependencies = [ "tauri-plugin-upload", "tempfile", "tokio", + "tokio-util", + "uuid", "zip 2.4.2", ] @@ -3578,6 +3737,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3811,6 +3980,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3868,6 +4048,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_hc" version = "0.2.0" @@ -3940,7 +4126,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4130,6 +4316,50 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rust_decimal" version = "1.37.2" @@ -4253,7 +4483,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4278,8 +4508,10 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.0.4", "serde", "serde_json", ] @@ -4293,7 +4525,19 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.117", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", ] [[package]] @@ -4366,10 +4610,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -4384,15 +4629,24 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4403,7 +4657,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4426,7 +4680,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4485,10 +4739,10 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4510,7 +4764,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4530,7 +4784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4541,7 +4795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4664,6 +4918,19 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4746,9 +5013,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4764,7 +5031,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4784,7 +5051,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4869,7 +5136,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4986,7 +5253,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.104", + "syn 2.0.117", "tauri-utils", "thiserror 2.0.12", "time", @@ -5004,7 +5271,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] @@ -5336,7 +5603,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5347,7 +5614,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5437,7 +5704,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5460,6 +5727,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -5469,6 +5747,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5633,7 +5912,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5749,6 +6028,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -5799,13 +6084,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.17.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.1", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -5902,6 +6187,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -5924,7 +6227,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -5959,7 +6262,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5973,6 +6276,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.10.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -5986,6 +6311,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.9.1", + "hashbrown 0.15.4", + "indexmap 2.10.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -6141,7 +6478,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6255,7 +6592,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6266,7 +6603,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6663,6 +7000,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -6672,6 +7029,74 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.10.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.9.1", + "indexmap 2.10.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.10.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.1" @@ -6802,7 +7227,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -6897,7 +7322,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "zbus_names 4.3.1", "zvariant 5.9.2", "zvariant_utils 3.3.0", @@ -6942,7 +7367,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6962,7 +7387,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -6983,7 +7408,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7016,7 +7441,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7151,7 +7576,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "zvariant_utils 3.3.0", ] @@ -7175,6 +7600,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.104", + "syn 2.0.117", "winnow 0.7.12", ] diff --git a/packages/player/src-tauri/Cargo.toml b/packages/player/src-tauri/Cargo.toml index e9b429ed..7ff814ce 100644 --- a/packages/player/src-tauri/Cargo.toml +++ b/packages/player/src-tauri/Cargo.toml @@ -38,6 +38,11 @@ mockall = "0.14.0" tempfile = "3.10" tauri-plugin-opener = "2" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } +rmcp = { version = "0.16", features = ["server", "transport-streamable-http-server"] } +schemars = "1.0" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +tokio-util = { version = "0.7", features = ["rt"] } +uuid = { version = "1.20.0", features = ["v4"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/packages/player/src-tauri/src/lib.rs b/packages/player/src-tauri/src/lib.rs index 2e6bc2fb..d11d72a8 100644 --- a/packages/player/src-tauri/src/lib.rs +++ b/packages/player/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ pub mod commands; pub mod http; pub mod logging; +pub mod mcp; mod setup; pub mod stream_proxy; pub mod ytdlp; @@ -33,10 +34,14 @@ pub fn run() { http::http_fetch, ytdlp::ytdlp_search, ytdlp::ytdlp_get_stream, - logging::get_startup_logs + logging::get_startup_logs, + mcp::mcp_start, + mcp::mcp_stop, + mcp::mcp_respond ]) - .setup(|_app| { + .setup(|app| { logging::mark_startup_complete(); + mcp::init_mcp(app.handle().clone()); Ok(()) }) .run(tauri::generate_context!()) diff --git a/packages/player/src-tauri/src/mcp/bridge.rs b/packages/player/src-tauri/src/mcp/bridge.rs new file mode 100644 index 00000000..8a248b5f --- /dev/null +++ b/packages/player/src-tauri/src/mcp/bridge.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{oneshot, Mutex}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpBridgeRequest { + pub trace_id: String, + pub tool_name: String, + pub arguments: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpBridgeResponse { + pub trace_id: String, + pub success: bool, + pub data: Option, + pub error: Option, +} + +#[derive(Debug)] +pub enum BridgeError { + InfrastructureError(String), + ToolError(String), +} + +impl fmt::Display for BridgeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BridgeError::InfrastructureError(message) => write!(formatter, "{message}"), + BridgeError::ToolError(message) => write!(formatter, "{message}"), + } + } +} + +type PendingRequests = Arc>>>; + +#[derive(Clone)] +pub struct McpBridge { + app_handle: AppHandle, + pending: PendingRequests, +} + +impl McpBridge { + pub fn new(app_handle: AppHandle) -> Self { + Self { + app_handle, + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn call_tool( + &self, + tool_name: &str, + arguments: serde_json::Value, + ) -> Result { + let trace_id = Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + + { + let mut pending = self.pending.lock().await; + pending.insert(trace_id.clone(), sender); + } + + let request = McpBridgeRequest { + trace_id: trace_id.clone(), + tool_name: tool_name.to_string(), + arguments, + }; + + self.app_handle + .emit("mcp:tool-call", &request) + .map_err(|err| { + BridgeError::InfrastructureError(format!("Failed to emit event: {err}")) + })?; + + let response = + match tokio::time::timeout(std::time::Duration::from_secs(30), receiver).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + self.pending.lock().await.remove(&trace_id); + return Err(BridgeError::InfrastructureError( + "Response channel closed unexpectedly".into(), + )); + } + Err(_) => { + self.pending.lock().await.remove(&trace_id); + return Err(BridgeError::InfrastructureError( + "Timed out after 30s".into(), + )); + } + }; + + if response.success { + Ok(response.data.unwrap_or(serde_json::Value::Null)) + } else { + Err(BridgeError::ToolError( + response + .error + .unwrap_or_else(|| "Unknown error".to_string()), + )) + } + } + + pub async fn handle_response(&self, response: McpBridgeResponse) { + let mut pending = self.pending.lock().await; + if let Some(sender) = pending.remove(&response.trace_id) { + let _ = sender.send(response); + } else { + log::warn!( + "Received MCP response for unknown trace ID: {}", + response.trace_id + ); + } + } +} diff --git a/packages/player/src-tauri/src/mcp/mod.rs b/packages/player/src-tauri/src/mcp/mod.rs new file mode 100644 index 00000000..812353ed --- /dev/null +++ b/packages/player/src-tauri/src/mcp/mod.rs @@ -0,0 +1,159 @@ +pub mod bridge; +pub mod tools; + +use std::sync::Arc; + +use bridge::{McpBridge, McpBridgeResponse}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::{model::*, tool_handler, ServerHandler}; +use tauri::{AppHandle, Manager}; +use tokio::sync::{oneshot, Mutex}; +use tokio_util::sync::CancellationToken; +use tools::NuclearMcpServer; + +const MCP_PORT_START: u16 = 8800; +const MCP_PORT_END: u16 = 8809; + +#[tool_handler] +impl ServerHandler for NuclearMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "Nuclear Music Player MCP server. Use list_methods to discover domains, method_details for parameter info, describe_type for data type shapes, and call to execute methods.".into(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +struct RunningServer { + task: tauri::async_runtime::JoinHandle<()>, + cancellation_token: CancellationToken, + port: u16, +} + +pub struct McpState { + bridge: McpBridge, + running: Arc>>, +} + +impl McpState { + fn new(app_handle: AppHandle) -> Self { + Self { + bridge: McpBridge::new(app_handle), + running: Arc::new(Mutex::new(None)), + } + } +} + +async fn try_bind(port_start: u16, port_end: u16) -> Result { + let mut last_error = String::new(); + for port in port_start..=port_end { + match tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await { + Ok(listener) => return Ok(listener), + Err(err) => { + log::debug!("Port {port} unavailable: {err}"); + last_error = format!("{err}"); + } + } + } + Err(format!( + "No available port in range {port_start}-{port_end}: {last_error}" + )) +} + +async fn start_server( + bridge: McpBridge, + ct: CancellationToken, + ready: oneshot::Sender>, +) { + let service = StreamableHttpService::new( + move || Ok(NuclearMcpServer::new(bridge.clone())), + LocalSessionManager::default().into(), + StreamableHttpServerConfig { + cancellation_token: ct.child_token(), + ..Default::default() + }, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + + let tcp_listener = match try_bind(MCP_PORT_START, MCP_PORT_END).await { + Ok(listener) => listener, + Err(message) => { + log::error!("Failed to bind MCP server: {message}"); + let _ = ready.send(Err(message)); + return; + } + }; + + let bound_port = tcp_listener.local_addr().unwrap().port(); + log::info!("MCP server listening on http://127.0.0.1:{bound_port}/mcp"); + let _ = ready.send(Ok(bound_port)); + + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { + ct.cancelled().await; + }) + .await; + + log::info!("MCP server stopped"); +} + +pub fn init_mcp(app_handle: AppHandle) { + let state = McpState::new(app_handle.clone()); + app_handle.manage(state); +} + +#[tauri::command] +pub async fn mcp_start(state: tauri::State<'_, McpState>) -> Result { + let mut guard = state.running.lock().await; + if let Some(server) = guard.as_ref() { + log::info!("MCP server already running on port {}", server.port); + return Ok(server.port); + } + + log::info!("Starting MCP server"); + let ct = CancellationToken::new(); + let (ready_tx, ready_rx) = oneshot::channel(); + let task = + tauri::async_runtime::spawn(start_server(state.bridge.clone(), ct.clone(), ready_tx)); + + match ready_rx.await { + Ok(Ok(port)) => { + *guard = Some(RunningServer { + task, + cancellation_token: ct, + port, + }); + Ok(port) + } + Ok(Err(message)) => Err(message), + Err(_) => Err("MCP server task exited before reporting ready".into()), + } +} + +#[tauri::command] +pub async fn mcp_stop(state: tauri::State<'_, McpState>) -> Result<(), String> { + let mut guard = state.running.lock().await; + if let Some(server) = guard.take() { + log::info!("Stopping MCP server"); + server.cancellation_token.cancel(); + let _ = server.task.await; + } else { + log::info!("MCP server already stopped"); + } + Ok(()) +} + +#[tauri::command] +pub async fn mcp_respond( + state: tauri::State<'_, McpState>, + response: McpBridgeResponse, +) -> Result<(), String> { + state.bridge.handle_response(response).await; + Ok(()) +} diff --git a/packages/player/src-tauri/src/mcp/tools.rs b/packages/player/src-tauri/src/mcp/tools.rs new file mode 100644 index 00000000..ea7f6120 --- /dev/null +++ b/packages/player/src-tauri/src/mcp/tools.rs @@ -0,0 +1,137 @@ +use rmcp::{ + handler::server::router::tool::ToolRouter, handler::server::wrapper::Parameters, model::*, + schemars, tool, tool_router, ErrorData as McpError, +}; + +use super::bridge::{BridgeError, McpBridge}; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ListMethodsParams { + pub domain: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct MethodDetailsParams { + /// The method to describe, in "Domain.method" format, e.g. "Queue.addToQueue". + pub method: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct DescribeTypeParams { + #[serde(rename = "type")] + pub type_name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CallParams { + /// The method to call, in "Domain.method" format, e.g. "Queue.addToQueue". + pub method: String, + /// Method parameters as a JSON object with named fields. Omit or pass {} for parameterless methods. + #[serde(default)] + pub params: serde_json::Value, +} + +fn bridge_result_to_mcp( + tool_label: &str, + result: Result, +) -> Result { + match result { + Ok(data) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&data).unwrap_or_default(), + )])), + Err(BridgeError::InfrastructureError(message)) => { + log::error!("MCP {tool_label} infrastructure error: {message}"); + Err(McpError::internal_error(message, None)) + } + Err(BridgeError::ToolError(message)) => { + Ok(CallToolResult::error(vec![Content::text(message)])) + } + } +} + +#[derive(Clone)] +pub struct NuclearMcpServer { + pub bridge: McpBridge, + pub(crate) tool_router: ToolRouter, +} + +#[tool_router] +impl NuclearMcpServer { + pub fn new(bridge: McpBridge) -> Self { + Self { + bridge, + tool_router: Self::tool_router(), + } + } + + #[tool( + name = "list_methods", + description = "List available methods in a Nuclear API domain. Available domains: Queue, Playback, Metadata, Favorites, Playlists, Dashboard, Providers." + )] + async fn list_methods( + &self, + Parameters(params): Parameters, + ) -> Result { + bridge_result_to_mcp( + &format!("list_methods({})", params.domain), + self.bridge + .call_tool( + "list_methods", + serde_json::to_value(¶ms.domain).unwrap(), + ) + .await, + ) + } + + #[tool( + name = "method_details", + description = "Get full details for a Nuclear API method: description, parameter names and types, return type. Use Domain.method format." + )] + async fn method_details( + &self, + Parameters(params): Parameters, + ) -> Result { + bridge_result_to_mcp( + &format!("method_details({})", params.method), + self.bridge + .call_tool( + "method_details", + serde_json::to_value(¶ms.method).unwrap(), + ) + .await, + ) + } + + #[tool( + name = "describe_type", + description = "Get the JSON shape of a Nuclear data type. Use when a method parameter or return type references a complex type like Track, Queue, QueueItem, etc." + )] + async fn describe_type( + &self, + Parameters(params): Parameters, + ) -> Result { + bridge_result_to_mcp( + &format!("describe_type({})", params.type_name), + self.bridge + .call_tool( + "describe_type", + serde_json::to_value(¶ms.type_name).unwrap(), + ) + .await, + ) + } + + #[tool( + name = "call", + description = "Call a Nuclear music player API method. Use Domain.method format for the method name and pass parameters as a JSON object with named fields." + )] + async fn call( + &self, + Parameters(params): Parameters, + ) -> Result { + bridge_result_to_mcp( + &format!("call({})", params.method), + self.bridge.call_tool(¶ms.method, params.params).await, + ) + } +} diff --git a/packages/player/src/main.tsx b/packages/player/src/main.tsx index 5edc296a..e2f7e3be 100644 --- a/packages/player/src/main.tsx +++ b/packages/player/src/main.tsx @@ -19,6 +19,7 @@ import { applyLanguageFromSettings, initLanguageWatcher, } from './services/languageService'; +import { initMcpHandler } from './services/mcp'; import { hydratePluginsFromRegistry } from './services/plugins/pluginBootstrap'; import { applyThemeFromSettings } from './services/themeBootstrap'; import { useUpdaterStore } from './stores/updaterStore'; @@ -30,6 +31,7 @@ initializeSettingsStore() .then(() => initializeFavoritesStore()) .then(() => initializePlaylistStore()) .then(() => registerBuiltInCoreSettings()) + .then(() => initMcpHandler()) .then(() => applyLanguageFromSettings()) .then(() => initLanguageWatcher()) .then(() => startAdvancedThemeWatcher()) diff --git a/packages/player/src/services/coreSettings.ts b/packages/player/src/services/coreSettings.ts index 030cdcf2..a846b69b 100644 --- a/packages/player/src/services/coreSettings.ts +++ b/packages/player/src/services/coreSettings.ts @@ -168,6 +168,24 @@ export const CORE_SETTINGS: SettingDefinition[] = [ default: false, widget: { type: 'toggle' }, }, + { + id: 'integrations.mcp.enabled', + title: 'preferences.integrations.mcp.enabled.title', + description: 'preferences.integrations.mcp.enabled.description', + category: 'integrations', + kind: 'boolean', + default: false, + widget: { type: 'toggle' }, + }, + { + id: 'integrations.mcp.serverUrl', + title: 'preferences.integrations.mcp.serverUrl.title', + description: 'preferences.integrations.mcp.serverUrl.description', + category: 'integrations', + kind: 'string', + default: 'http://127.0.0.1:8800/mcp', + widget: { type: 'info' }, + }, ]; export const registerBuiltInCoreSettings = () => { diff --git a/packages/player/src/services/logger.ts b/packages/player/src/services/logger.ts index a78b1155..ae23a9e7 100644 --- a/packages/player/src/services/logger.ts +++ b/packages/player/src/services/logger.ts @@ -15,6 +15,7 @@ export type ScopedLogger = { export const LOG_SCOPES = [ 'app', 'dashboard', + 'mcp', 'playback', 'streaming', 'plugins', diff --git a/packages/player/src/services/mcp/index.ts b/packages/player/src/services/mcp/index.ts new file mode 100644 index 00000000..c66bdcba --- /dev/null +++ b/packages/player/src/services/mcp/index.ts @@ -0,0 +1 @@ +export { initMcpHandler } from './mcpHandler'; diff --git a/packages/player/src/services/mcp/mcpDispatcher.ts b/packages/player/src/services/mcp/mcpDispatcher.ts new file mode 100644 index 00000000..efd4997b --- /dev/null +++ b/packages/player/src/services/mcp/mcpDispatcher.ts @@ -0,0 +1,39 @@ +import { NuclearAPI, NuclearPluginAPI } from '@nuclearplayer/plugin-sdk'; +import { apiMeta } from '@nuclearplayer/plugin-sdk/mcp'; + +type DomainOf = NuclearAPI[K]; +type MethodOf = { + [K in keyof D]: D[K] extends (...args: never[]) => unknown ? K : never; +}[keyof D]; + +const getDomainMethod = ( + api: NuclearPluginAPI, + domain: K, + methodName: MethodOf>, +) => { + const domainInstance = api[domain]; + return { + domainInstance, + fn: domainInstance[methodName as keyof typeof domainInstance] as ( + ...args: never[] + ) => Promise, + }; +}; + +export const dispatch = async ( + api: NuclearPluginAPI, + method: string, + params: Record, +): Promise => { + const [domain, methodName] = method.split('.', 2); + const methodMeta = apiMeta[domain].methods[methodName]; + + const { domainInstance, fn } = getDomainMethod( + api, + domain as keyof NuclearAPI, + methodName as MethodOf>, + ); + + const positionalArgs = methodMeta.params.map((param) => params[param.name]); + return fn.apply(domainInstance, positionalArgs as never[]) ?? null; +}; diff --git a/packages/player/src/services/mcp/mcpHandler.ts b/packages/player/src/services/mcp/mcpHandler.ts new file mode 100644 index 00000000..4ec6a50f --- /dev/null +++ b/packages/player/src/services/mcp/mcpHandler.ts @@ -0,0 +1,155 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { z } from 'zod'; + +import { NuclearPluginAPI } from '@nuclearplayer/plugin-sdk'; +import { + apiMeta, + DomainMeta, + typeRegistry, +} from '@nuclearplayer/plugin-sdk/mcp'; + +import { + getSetting, + setSetting, + useSettingsStore, +} from '../../stores/settingsStore'; +import { errorMessage } from '../../utils/error'; +import { Logger } from '../logger'; +import { createPluginAPI } from '../plugins/createPluginAPI'; +import { dispatch } from './mcpDispatcher'; + +const MCP_ENABLED_SETTING = 'core.integrations.mcp.enabled'; +const MCP_SERVER_URL_SETTING = 'core.integrations.mcp.serverUrl'; + +const bridgeRequestSchema = z.object({ + traceId: z.string(), + toolName: z.string(), + arguments: z.unknown(), +}); + +type BridgeRequest = z.infer; + +type BridgeResponse = { + traceId: string; + success: boolean; + data?: unknown; + error?: string; +}; + +const mcpApi: NuclearPluginAPI = createPluginAPI('mcp-server', 'MCP Server'); + +const respond = (response: BridgeResponse) => + invoke('mcp_respond', { response }); + +const stringArg = z.string(); +const paramsArg = z.record(z.string(), z.unknown()).default({}); + +type ToolHandler = (args: unknown) => unknown | Promise; + +const availableDomains = () => Object.keys(apiMeta).join(', '); + +const getDomain = (name: string): DomainMeta => { + const domain = apiMeta[name]; + if (!domain) { + throw new Error( + `Unknown domain "${name}". Available: ${availableDomains()}`, + ); + } + return domain; +}; + +const discoveryHandlers: Record = { + list_methods: (args) => + Object.values(getDomain(stringArg.parse(args)).methods), + method_details: (args) => { + const [domainName, methodName] = stringArg.parse(args).split('.', 2); + const domain = getDomain(domainName); + const method = domain.methods[methodName]; + if (!method) { + throw new Error( + `Unknown method "${methodName}" in domain "${domainName}". Available: ${Object.keys(domain.methods).join(', ')}`, + ); + } + return method; + }, + describe_type: (args) => { + const typeName = stringArg.parse(args); + const shape = typeRegistry[typeName]; + if (!shape) { + throw new Error( + `Unknown type "${typeName}". Available: ${Object.keys(typeRegistry).join(', ')}`, + ); + } + return shape; + }, +}; + +const getToolHandler = (toolName: string): ToolHandler => + discoveryHandlers[toolName] ?? + ((args) => dispatch(mcpApi, toolName, paramsArg.parse(args))); + +const handleToolCall = async (request: BridgeRequest): Promise => { + try { + const handler = getToolHandler(request.toolName); + const data = await handler(request.arguments); + await respond({ traceId: request.traceId, success: true, data }); + } catch (error) { + const message = errorMessage(error); + void Logger.mcp.error( + `MCP tool call failed (${request.toolName}): ${message}`, + ); + await respond({ + traceId: request.traceId, + success: false, + error: message, + }); + } +}; + +const startServer = async () => { + const port = await invoke('mcp_start'); + const url = `http://127.0.0.1:${port}/mcp`; + await setSetting(MCP_SERVER_URL_SETTING, url); + Logger.mcp.info(`MCP server started on ${url}`); +}; + +const stopServer = () => invoke('mcp_stop'); + +const watchSettings = () => { + let previouslyEnabled = getSetting(MCP_ENABLED_SETTING) === true; + + useSettingsStore.subscribe((state) => { + const enabled = state.getValue(MCP_ENABLED_SETTING) === true; + if (enabled === previouslyEnabled) { + return; + } + previouslyEnabled = enabled; + + if (enabled) { + Logger.mcp.info('MCP server enabled'); + startServer().catch((err) => + Logger.mcp.error(`Failed to start MCP server: ${errorMessage(err)}`), + ); + } else { + Logger.mcp.info('MCP server disabled'); + stopServer().catch((err) => + Logger.mcp.error(`Failed to stop MCP server: ${errorMessage(err)}`), + ); + } + }); +}; + +export const initMcpHandler = async () => { + await listen('mcp:tool-call', (event) => { + const request = bridgeRequestSchema.parse(event.payload); + void handleToolCall(request); + }); + + watchSettings(); + + if (getSetting(MCP_ENABLED_SETTING) === true) { + Logger.mcp.info('MCP server enabled on startup'); + await startServer(); + } +}; diff --git a/packages/player/src/utils/error.ts b/packages/player/src/utils/error.ts new file mode 100644 index 00000000..f1a3a3fa --- /dev/null +++ b/packages/player/src/utils/error.ts @@ -0,0 +1,2 @@ +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); diff --git a/packages/player/src/views/Settings/InfoField.tsx b/packages/player/src/views/Settings/InfoField.tsx new file mode 100644 index 00000000..1be10b41 --- /dev/null +++ b/packages/player/src/views/Settings/InfoField.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; + +import { CopyButton } from '@nuclearplayer/ui'; + +type Props = { + label: string; + description?: string; + value: string | undefined; +}; + +export const InfoField: FC = ({ label, description, value }) => ( +
+ {label} +
+
+ {value} +
+ +
+ {description && ( +

+ {description} +

+ )} +
+); diff --git a/packages/player/src/views/Settings/SettingField.tsx b/packages/player/src/views/Settings/SettingField.tsx index d05c854c..f97173eb 100644 --- a/packages/player/src/views/Settings/SettingField.tsx +++ b/packages/player/src/views/Settings/SettingField.tsx @@ -7,6 +7,7 @@ import type { SettingValue, } from '@nuclearplayer/plugin-sdk'; +import { InfoField } from './InfoField'; import { NumberInputField } from './NumberInputField'; import { SelectField } from './SelectField'; import { SliderField } from './SliderField'; @@ -119,6 +120,13 @@ export const SettingField: FC = ({ variant="text" /> ), + info: () => ( + + ), }; const renderer = widgetType ? renderers[widgetType] : renderers.text; diff --git a/packages/player/src/views/Settings/__snapshots__/Settings.test.tsx.snap b/packages/player/src/views/Settings/__snapshots__/Settings.test.tsx.snap index a417f209..f197f172 100644 --- a/packages/player/src/views/Settings/__snapshots__/Settings.test.tsx.snap +++ b/packages/player/src/views/Settings/__snapshots__/Settings.test.tsx.snap @@ -335,6 +335,111 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` +
+

+ Integrations +

+
+
+
+
+ + + Enable MCP Server + +
+

+ Start a local MCP server that allows AI tools to control Nuclear. +

+
+
+ + MCP Server URL + +
+
+ http://127.0.0.1:8800/mcp +
+ +
+

+ Point your AI tool to this URL to connect to Nuclear. +

+
+
+
+
@@ -354,22 +459,22 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` >

Number of seconds to skip forward/backward

@@ -379,22 +484,22 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` >

Crossfade duration between tracks in milliseconds

@@ -404,22 +509,22 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` >

How long before a resolved stream URL is considered expired and needs re-resolution

@@ -429,22 +534,22 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` >

Number of times to retry resolving a stream URL before moving to the next candidate

@@ -478,7 +583,7 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` class="border-border relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full border-2 transition-all focus:ring-2 focus:ring-black focus:ring-offset-2 focus:outline-none data-checked:bg-primary bg-white data-disabled:cursor-not-allowed data-disabled:opacity-50 group" data-checked="" data-headlessui-state="checked" - id="headlessui-switch-:rn:" + id="headlessui-switch-:ro:" role="switch" tabindex="0" type="button" @@ -510,7 +615,7 @@ exports[`Settings view > (Snapshot) renders the settings view 1`] = ` aria-label="Auto-install updates" class="border-border relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full border-2 transition-all focus:ring-2 focus:ring-black focus:ring-offset-2 focus:outline-none data-checked:bg-primary bg-white data-disabled:cursor-not-allowed data-disabled:opacity-50 group" data-headlessui-state="" - id="headlessui-switch-:ro:" + id="headlessui-switch-:rp:" role="switch" tabindex="0" type="button" diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 574e4173..eedd5dde 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -10,6 +10,9 @@ ".": { "import": "./src/index.ts", "types": "./dist/index.d.ts" + }, + "./mcp": { + "import": "./src/mcp/index.ts" } }, "files": [ @@ -20,7 +23,7 @@ "dev": "vite", "build": "tsc && vite build && api-extractor run --local && pnpm clean:dts", "build:npm": "tsc && vite build --mode npm && api-extractor run --local && pnpm clean:dts", - "clean:dts": "rm -rf dist/api dist/react dist/test dist/types dist/types.d.ts dist/*.d.ts.map dist/tsdoc-metadata.json", + "clean:dts": "rm -rf dist/api dist/mcp dist/react dist/test dist/types dist/types.d.ts dist/*.d.ts.map dist/tsdoc-metadata.json", "test": "vitest --run", "test:watch": "vitest", "test:coverage": "vitest --run --coverage", diff --git a/packages/plugin-sdk/src/mcp/dashboard.meta.ts b/packages/plugin-sdk/src/mcp/dashboard.meta.ts new file mode 100644 index 00000000..cef40cf1 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/dashboard.meta.ts @@ -0,0 +1,42 @@ +import type { DomainMeta } from './meta'; + +export const DashboardAPIMeta: DomainMeta = { + description: 'Fetch trending and editorial content from music providers.', + methods: { + fetchTopTracks: { + name: 'fetchTopTracks', + description: + 'Fetch top/trending tracks, optionally from a specific provider.', + params: [{ name: 'providerId', type: 'string?' }], + returns: 'AttributedResult[]', + }, + fetchTopArtists: { + name: 'fetchTopArtists', + description: + 'Fetch top/trending artists, optionally from a specific provider.', + params: [{ name: 'providerId', type: 'string?' }], + returns: 'AttributedResult[]', + }, + fetchTopAlbums: { + name: 'fetchTopAlbums', + description: + 'Fetch top/trending albums, optionally from a specific provider.', + params: [{ name: 'providerId', type: 'string?' }], + returns: 'AttributedResult[]', + }, + fetchEditorialPlaylists: { + name: 'fetchEditorialPlaylists', + description: + 'Fetch editorial/curated playlists, optionally from a specific provider.', + params: [{ name: 'providerId', type: 'string?' }], + returns: 'AttributedResult[]', + }, + fetchNewReleases: { + name: 'fetchNewReleases', + description: + 'Fetch new album releases, optionally from a specific provider.', + params: [{ name: 'providerId', type: 'string?' }], + returns: 'AttributedResult[]', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/favorites.meta.ts b/packages/plugin-sdk/src/mcp/favorites.meta.ts new file mode 100644 index 00000000..e5d5485e --- /dev/null +++ b/packages/plugin-sdk/src/mcp/favorites.meta.ts @@ -0,0 +1,79 @@ +import type { DomainMeta } from './meta'; + +export const FavoritesAPIMeta: DomainMeta = { + description: 'Manage favorite tracks, albums, and artists.', + methods: { + getTracks: { + name: 'getTracks', + description: 'Get all favorite tracks.', + params: [], + returns: 'FavoriteEntry[]', + }, + getAlbums: { + name: 'getAlbums', + description: 'Get all favorite albums.', + params: [], + returns: 'FavoriteEntry[]', + }, + getArtists: { + name: 'getArtists', + description: 'Get all favorite artists.', + params: [], + returns: 'FavoriteEntry[]', + }, + addTrack: { + name: 'addTrack', + description: 'Add a track to favorites.', + params: [{ name: 'track', type: 'Track' }], + returns: 'void', + }, + removeTrack: { + name: 'removeTrack', + description: 'Remove a track from favorites by its provider reference.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'void', + }, + isTrackFavorite: { + name: 'isTrackFavorite', + description: 'Check if a track is in favorites.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'boolean', + }, + addAlbum: { + name: 'addAlbum', + description: 'Add an album to favorites.', + params: [{ name: 'ref', type: 'AlbumRef' }], + returns: 'void', + }, + removeAlbum: { + name: 'removeAlbum', + description: 'Remove an album from favorites by its provider reference.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'void', + }, + isAlbumFavorite: { + name: 'isAlbumFavorite', + description: 'Check if an album is in favorites.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'boolean', + }, + addArtist: { + name: 'addArtist', + description: 'Add an artist to favorites.', + params: [{ name: 'ref', type: 'ArtistRef' }], + returns: 'void', + }, + removeArtist: { + name: 'removeArtist', + description: 'Remove an artist from favorites by its provider reference.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'void', + }, + isArtistFavorite: { + name: 'isArtistFavorite', + description: 'Check if an artist is in favorites.', + params: [{ name: 'source', type: 'ProviderRef' }], + returns: 'boolean', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/index.ts b/packages/plugin-sdk/src/mcp/index.ts new file mode 100644 index 00000000..0dffc5da --- /dev/null +++ b/packages/plugin-sdk/src/mcp/index.ts @@ -0,0 +1,4 @@ +export { apiMeta } from './meta'; +export type { ParamMeta, MethodMeta, DomainMeta, ApiMeta } from './meta'; +export { typeRegistry } from './typeRegistry'; +export type { TypeField, TypeShape, TypeRegistry } from './typeRegistry'; diff --git a/packages/plugin-sdk/src/mcp/meta.ts b/packages/plugin-sdk/src/mcp/meta.ts new file mode 100644 index 00000000..00b22e04 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/meta.ts @@ -0,0 +1,36 @@ +import { DashboardAPIMeta } from './dashboard.meta'; +import { FavoritesAPIMeta } from './favorites.meta'; +import { MetadataAPIMeta } from './metadata.meta'; +import { PlaybackAPIMeta } from './playback.meta'; +import { PlaylistsAPIMeta } from './playlists.meta'; +import { ProvidersAPIMeta } from './providers.meta'; +import { QueueAPIMeta } from './queue.meta'; + +export type ParamMeta = { + name: string; + type: string; +}; + +export type MethodMeta = { + name: string; + description: string; + params: ParamMeta[]; + returns: string; +}; + +export type DomainMeta = { + description: string; + methods: Record; +}; + +export type ApiMeta = Record; + +export const apiMeta: ApiMeta = { + Queue: QueueAPIMeta, + Playback: PlaybackAPIMeta, + Metadata: MetadataAPIMeta, + Favorites: FavoritesAPIMeta, + Playlists: PlaylistsAPIMeta, + Dashboard: DashboardAPIMeta, + Providers: ProvidersAPIMeta, +}; diff --git a/packages/plugin-sdk/src/mcp/metadata.meta.ts b/packages/plugin-sdk/src/mcp/metadata.meta.ts new file mode 100644 index 00000000..4a683855 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/metadata.meta.ts @@ -0,0 +1,80 @@ +import type { DomainMeta } from './meta'; + +export const MetadataAPIMeta: DomainMeta = { + description: 'Search for music and fetch artist, album, and track metadata.', + methods: { + search: { + name: 'search', + description: 'Search for artists, albums, tracks, and playlists.', + params: [ + { name: 'params', type: 'SearchParams' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'SearchResults', + }, + fetchArtistBio: { + name: 'fetchArtistBio', + description: "Fetch an artist's biography and tags.", + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'ArtistBio', + }, + fetchArtistSocialStats: { + name: 'fetchArtistSocialStats', + description: + "Fetch an artist's social media stats (followers, track count, etc.).", + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'ArtistSocialStats', + }, + fetchArtistAlbums: { + name: 'fetchArtistAlbums', + description: "Fetch an artist's album discography.", + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'AlbumRef[]', + }, + fetchArtistTopTracks: { + name: 'fetchArtistTopTracks', + description: "Fetch an artist's most popular tracks.", + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'TrackRef[]', + }, + fetchArtistPlaylists: { + name: 'fetchArtistPlaylists', + description: 'Fetch playlists associated with an artist.', + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'PlaylistRef[]', + }, + fetchArtistRelatedArtists: { + name: 'fetchArtistRelatedArtists', + description: 'Fetch artists similar to the given artist.', + params: [ + { name: 'artistId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'ArtistRef[]', + }, + fetchAlbumDetails: { + name: 'fetchAlbumDetails', + description: 'Fetch full album details including track listing.', + params: [ + { name: 'albumId', type: 'string' }, + { name: 'providerId', type: 'string?' }, + ], + returns: 'Album', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/playback.meta.ts b/packages/plugin-sdk/src/mcp/playback.meta.ts new file mode 100644 index 00000000..9c21fde5 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/playback.meta.ts @@ -0,0 +1,44 @@ +import type { DomainMeta } from './meta'; + +export const PlaybackAPIMeta: DomainMeta = { + description: 'Control audio playback state, transport, and seeking.', + methods: { + getState: { + name: 'getState', + description: + 'Get the current playback state (status, seek position, duration).', + params: [], + returns: 'PlaybackState', + }, + play: { + name: 'play', + description: 'Start or resume playback.', + params: [], + returns: 'void', + }, + pause: { + name: 'pause', + description: 'Pause playback.', + params: [], + returns: 'void', + }, + stop: { + name: 'stop', + description: 'Stop playback and reset position.', + params: [], + returns: 'void', + }, + toggle: { + name: 'toggle', + description: 'Toggle between play and pause.', + params: [], + returns: 'void', + }, + seekTo: { + name: 'seekTo', + description: 'Seek to a position in seconds.', + params: [{ name: 'seconds', type: 'number' }], + returns: 'void', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/playlists.meta.ts b/packages/plugin-sdk/src/mcp/playlists.meta.ts new file mode 100644 index 00000000..db08a528 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/playlists.meta.ts @@ -0,0 +1,75 @@ +import type { DomainMeta } from './meta'; + +export const PlaylistsAPIMeta: DomainMeta = { + description: 'Create, edit, import, and manage playlists.', + methods: { + getIndex: { + name: 'getIndex', + description: 'Get the list of all playlists with summary info.', + params: [], + returns: 'PlaylistIndexEntry[]', + }, + getPlaylist: { + name: 'getPlaylist', + description: 'Get a playlist by ID with all its items.', + params: [{ name: 'id', type: 'string' }], + returns: 'Playlist | null', + }, + createPlaylist: { + name: 'createPlaylist', + description: 'Create a new empty playlist. Returns the playlist ID.', + params: [{ name: 'name', type: 'string' }], + returns: 'string', + }, + deletePlaylist: { + name: 'deletePlaylist', + description: 'Delete a playlist by ID.', + params: [{ name: 'id', type: 'string' }], + returns: 'void', + }, + addTracks: { + name: 'addTracks', + description: + 'Add tracks to a playlist. Returns the created playlist items.', + params: [ + { name: 'playlistId', type: 'string' }, + { name: 'tracks', type: 'Track[]' }, + ], + returns: 'PlaylistItem[]', + }, + removeTracks: { + name: 'removeTracks', + description: 'Remove items from a playlist by their item IDs.', + params: [ + { name: 'playlistId', type: 'string' }, + { name: 'itemIds', type: 'string[]' }, + ], + returns: 'void', + }, + reorderTracks: { + name: 'reorderTracks', + description: + 'Move a track within a playlist from one position to another.', + params: [ + { name: 'playlistId', type: 'string' }, + { name: 'from', type: 'number' }, + { name: 'to', type: 'number' }, + ], + returns: 'void', + }, + importPlaylist: { + name: 'importPlaylist', + description: + 'Import a full playlist object. Returns the new playlist ID.', + params: [{ name: 'playlist', type: 'Playlist' }], + returns: 'string', + }, + saveQueueAsPlaylist: { + name: 'saveQueueAsPlaylist', + description: + 'Save the current queue as a new playlist. Returns the playlist ID.', + params: [{ name: 'name', type: 'string' }], + returns: 'string', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/providers.meta.ts b/packages/plugin-sdk/src/mcp/providers.meta.ts new file mode 100644 index 00000000..e7eb4f54 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/providers.meta.ts @@ -0,0 +1,24 @@ +import type { DomainMeta } from './meta'; + +export const ProvidersAPIMeta: DomainMeta = { + description: + 'Query registered music providers (metadata, streaming, dashboard, etc.).', + methods: { + list: { + name: 'list', + description: + 'List all registered providers, optionally filtered by kind (metadata, streaming, lyrics, dashboard).', + params: [{ name: 'kind', type: 'string?' }], + returns: 'ProviderDescriptor[]', + }, + get: { + name: 'get', + description: 'Get a specific provider by ID and kind.', + params: [ + { name: 'id', type: 'string' }, + { name: 'kind', type: 'string' }, + ], + returns: 'ProviderDescriptor | undefined', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/queue.meta.ts b/packages/plugin-sdk/src/mcp/queue.meta.ts new file mode 100644 index 00000000..7a806be6 --- /dev/null +++ b/packages/plugin-sdk/src/mcp/queue.meta.ts @@ -0,0 +1,113 @@ +import type { DomainMeta } from './meta'; + +export const QueueAPIMeta: DomainMeta = { + description: + 'Manage the playback queue — add, remove, reorder tracks and control navigation.', + methods: { + getQueue: { + name: 'getQueue', + description: 'Get the current queue state.', + params: [], + returns: 'Queue', + }, + getCurrentItem: { + name: 'getCurrentItem', + description: 'Get the currently playing queue item.', + params: [], + returns: 'QueueItem | undefined', + }, + addToQueue: { + name: 'addToQueue', + description: 'Add tracks to the end of the queue.', + params: [{ name: 'tracks', type: 'Track[]' }], + returns: 'void', + }, + addNext: { + name: 'addNext', + description: 'Insert tracks immediately after the current item.', + params: [{ name: 'tracks', type: 'Track[]' }], + returns: 'void', + }, + addAt: { + name: 'addAt', + description: 'Insert tracks at a specific position.', + params: [ + { name: 'tracks', type: 'Track[]' }, + { name: 'index', type: 'number' }, + ], + returns: 'void', + }, + removeByIds: { + name: 'removeByIds', + description: 'Remove items from the queue by their IDs.', + params: [{ name: 'ids', type: 'string[]' }], + returns: 'void', + }, + removeByIndices: { + name: 'removeByIndices', + description: 'Remove items from the queue by their indices.', + params: [{ name: 'indices', type: 'number[]' }], + returns: 'void', + }, + clearQueue: { + name: 'clearQueue', + description: 'Remove all items from the queue.', + params: [], + returns: 'void', + }, + reorder: { + name: 'reorder', + description: 'Move a queue item from one position to another.', + params: [ + { name: 'fromIndex', type: 'number' }, + { name: 'toIndex', type: 'number' }, + ], + returns: 'void', + }, + goToNext: { + name: 'goToNext', + description: 'Skip to the next item.', + params: [], + returns: 'void', + }, + goToPrevious: { + name: 'goToPrevious', + description: 'Go back to the previous item.', + params: [], + returns: 'void', + }, + goToIndex: { + name: 'goToIndex', + description: 'Jump to a specific position in the queue.', + params: [{ name: 'index', type: 'number' }], + returns: 'void', + }, + goToId: { + name: 'goToId', + description: 'Jump to a specific queue item by its ID.', + params: [{ name: 'id', type: 'string' }], + returns: 'void', + }, + setRepeatMode: { + name: 'setRepeatMode', + description: 'Set the repeat mode.', + params: [{ name: 'mode', type: '"off" | "one" | "all"' }], + returns: 'void', + }, + setShuffleEnabled: { + name: 'setShuffleEnabled', + description: 'Enable or disable shuffle mode.', + params: [{ name: 'enabled', type: 'boolean' }], + returns: 'void', + }, + updateItemState: { + name: 'updateItemState', + description: 'Update the loading status of a queue item.', + params: [ + { name: 'id', type: 'string' }, + { name: 'updates', type: 'QueueItemStateUpdate' }, + ], + returns: 'void', + }, + }, +}; diff --git a/packages/plugin-sdk/src/mcp/typeRegistry.ts b/packages/plugin-sdk/src/mcp/typeRegistry.ts new file mode 100644 index 00000000..90dc3ebc --- /dev/null +++ b/packages/plugin-sdk/src/mcp/typeRegistry.ts @@ -0,0 +1,402 @@ +export type TypeField = { + type: string; + optional?: boolean; + description?: string; +}; + +export type TypeShape = { + description: string; + fields: Record; +}; + +export type TypeRegistry = Record; + +export const typeRegistry: TypeRegistry = { + ProviderRef: { + description: + 'A reference to an entity within a specific provider (e.g. a MusicBrainz artist, a YouTube video).', + fields: { + provider: { type: 'string', description: 'Provider identifier' }, + id: { type: 'string', description: 'Entity ID within the provider' }, + url: { + type: 'string', + optional: true, + description: 'URL to the entity on the provider', + }, + }, + }, + + ArtistCredit: { + description: 'A credited artist on a track or album, with roles.', + fields: { + name: { type: 'string' }, + roles: { + type: 'string[]', + description: 'e.g. ["performer", "composer"]', + }, + source: { type: 'ProviderRef', optional: true }, + }, + }, + + Artwork: { + description: 'A single image at a specific size and purpose.', + fields: { + url: { type: 'string' }, + width: { type: 'number', optional: true }, + height: { type: 'number', optional: true }, + purpose: { + type: '"avatar" | "cover" | "background" | "thumbnail"', + optional: true, + }, + source: { type: 'ProviderRef', optional: true }, + }, + }, + + ArtworkSet: { + description: + 'A collection of artwork images at different sizes and purposes.', + fields: { + items: { type: 'Artwork[]' }, + }, + }, + + ArtistRef: { + description: 'A lightweight reference to an artist entity.', + fields: { + name: { type: 'string' }, + disambiguation: { type: 'string', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + AlbumRef: { + description: 'A lightweight reference to an album.', + fields: { + title: { type: 'string' }, + artists: { type: 'ArtistRef[]', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + TrackRef: { + description: + 'A lightweight reference to a track (used in album track listings).', + fields: { + title: { type: 'string' }, + artists: { type: 'ArtistRef[]' }, + artwork: { type: 'ArtworkSet', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + LocalFileInfo: { + description: 'Metadata about a local audio file.', + fields: { + fileUri: { type: 'string' }, + fileSize: { type: 'number', optional: true }, + format: { type: 'string', optional: true }, + bitrateKbps: { type: 'number', optional: true }, + sampleRateHz: { type: 'number', optional: true }, + channels: { type: 'number', optional: true }, + fingerprint: { type: 'string', optional: true }, + scannedAtIso: { type: 'string', optional: true }, + }, + }, + + Stream: { + description: 'A resolved audio stream URL with quality metadata.', + fields: { + url: { type: 'string' }, + protocol: { type: '"file" | "http" | "https" | "hls"' }, + mimeType: { type: 'string', optional: true }, + bitrateKbps: { type: 'number', optional: true }, + codec: { type: 'string', optional: true }, + container: { type: 'string', optional: true }, + qualityLabel: { type: 'string', optional: true }, + durationMs: { type: 'number', optional: true }, + contentLengthBytes: { type: 'number', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + StreamCandidate: { + description: + 'A potential stream source for a track, possibly with a resolved stream.', + fields: { + id: { type: 'string' }, + title: { type: 'string' }, + durationMs: { type: 'number', optional: true }, + thumbnail: { type: 'string', optional: true }, + stream: { type: 'Stream', optional: true }, + lastResolvedAtIso: { type: 'string', optional: true }, + failed: { type: 'boolean' }, + source: { type: 'ProviderRef' }, + }, + }, + + Track: { + description: + 'A full track with metadata, artwork, and optional streaming info.', + fields: { + title: { type: 'string' }, + artists: { type: 'ArtistCredit[]' }, + album: { type: 'AlbumRef', optional: true }, + durationMs: { type: 'number', optional: true }, + trackNumber: { type: 'number', optional: true }, + disc: { type: 'string', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + tags: { type: 'string[]', optional: true }, + source: { type: 'ProviderRef' }, + localFile: { type: 'LocalFileInfo', optional: true }, + streamCandidates: { type: 'StreamCandidate[]', optional: true }, + }, + }, + + QueueItem: { + description: 'A track in the playback queue with its loading status.', + fields: { + id: { type: 'string' }, + track: { type: 'Track' }, + status: { type: '"idle" | "loading" | "success" | "error"' }, + error: { type: 'string', optional: true }, + addedAtIso: { type: 'string' }, + }, + }, + + Queue: { + description: 'The playback queue state.', + fields: { + items: { type: 'QueueItem[]' }, + currentIndex: { type: 'number' }, + repeatMode: { type: '"off" | "all" | "one"' }, + shuffleEnabled: { type: 'boolean' }, + }, + }, + + PlaylistRef: { + description: 'A lightweight reference to a playlist.', + fields: { + id: { type: 'string' }, + name: { type: 'string' }, + artwork: { type: 'ArtworkSet', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + PlaylistItem: { + description: 'A track within a playlist.', + fields: { + id: { type: 'string' }, + track: { type: 'Track' }, + note: { type: 'string', optional: true }, + addedAtIso: { type: 'string' }, + }, + }, + + Playlist: { + description: 'A full playlist with metadata and items.', + fields: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + tags: { type: 'string[]', optional: true }, + createdAtIso: { type: 'string' }, + lastModifiedIso: { type: 'string' }, + origin: { type: 'ProviderRef', optional: true }, + isReadOnly: { type: 'boolean' }, + parentId: { type: 'string', optional: true }, + items: { type: 'PlaylistItem[]' }, + }, + }, + + PlaylistIndexEntry: { + description: 'Summary info for a playlist in the index.', + fields: { + id: { type: 'string' }, + name: { type: 'string' }, + createdAtIso: { type: 'string' }, + lastModifiedIso: { type: 'string' }, + isReadOnly: { type: 'boolean' }, + artwork: { type: 'ArtworkSet', optional: true }, + itemCount: { type: 'number' }, + totalDurationMs: { type: 'number' }, + }, + }, + + SearchParams: { + description: 'Parameters for a music search query.', + fields: { + query: { type: 'string' }, + types: { + type: '"artists" | "albums" | "tracks" | "playlists"[]', + optional: true, + description: 'Categories to search', + }, + limit: { type: 'number', optional: true }, + }, + }, + + SearchResults: { + description: 'Results from a music search, grouped by category.', + fields: { + artists: { type: 'ArtistRef[]', optional: true }, + albums: { type: 'AlbumRef[]', optional: true }, + tracks: { type: 'Track[]', optional: true }, + playlists: { type: 'PlaylistRef[]', optional: true }, + }, + }, + + Album: { + description: 'A full album with track listing and metadata.', + fields: { + title: { type: 'string' }, + artists: { type: 'ArtistCredit[]' }, + tracks: { type: 'TrackRef[]', optional: true }, + releaseDate: { + type: 'ReleaseDate', + optional: true, + description: 'Release date with precision', + }, + genres: { type: 'string[]', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + ReleaseDate: { + description: 'A date with precision indicator.', + fields: { + precision: { type: '"year" | "month" | "day"' }, + dateIso: { type: 'string' }, + }, + }, + + ArtistBio: { + description: "An artist's biography, tags, and tour status.", + fields: { + name: { type: 'string' }, + disambiguation: { type: 'string', optional: true }, + bio: { type: 'string', optional: true }, + onTour: { type: 'boolean', optional: true }, + artwork: { type: 'ArtworkSet', optional: true }, + tags: { type: 'string[]', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + ArtistSocialStats: { + description: "An artist's social media and platform statistics.", + fields: { + name: { type: 'string' }, + artwork: { type: 'ArtworkSet', optional: true }, + city: { type: 'string', optional: true }, + country: { type: 'string', optional: true }, + followersCount: { type: 'number', optional: true }, + followingsCount: { type: 'number', optional: true }, + trackCount: { type: 'number', optional: true }, + playlistCount: { type: 'number', optional: true }, + source: { type: 'ProviderRef' }, + }, + }, + + PlaybackState: { + description: 'Current audio playback state.', + fields: { + status: { type: '"playing" | "paused" | "stopped"' }, + seek: { type: 'number', description: 'Current position in seconds' }, + duration: { type: 'number', description: 'Total duration in seconds' }, + }, + }, + + FavoriteEntry: { + description: + 'A favorited item with timestamp. The ref field contains the actual entity (Track, AlbumRef, or ArtistRef).', + fields: { + ref: { + type: 'Track | AlbumRef | ArtistRef', + description: 'The favorited entity', + }, + addedAtIso: { type: 'string' }, + }, + }, + + AttributedResult: { + description: 'A batch of results from a specific provider.', + fields: { + providerId: { type: 'string' }, + metadataProviderId: { type: 'string', optional: true }, + providerName: { type: 'string' }, + items: { + type: 'any[]', + description: + 'Array of result items (Track, ArtistRef, AlbumRef, or PlaylistRef depending on the endpoint)', + }, + }, + }, + + StreamResolutionResult: { + description: + 'Result of resolving stream candidates for a track. Either succeeds with candidates or fails with an error.', + fields: { + success: { type: 'boolean' }, + candidates: { + type: 'StreamCandidate[]', + optional: true, + description: 'Present when success is true', + }, + error: { + type: 'string', + optional: true, + description: 'Present when success is false', + }, + }, + }, + + ProviderDescriptor: { + description: + 'Describes a registered provider (metadata, streaming, dashboard, etc.).', + fields: { + id: { type: 'string' }, + kind: { + type: 'string', + description: + 'Provider kind: metadata, streaming, lyrics, dashboard, etc.', + }, + name: { type: 'string' }, + pluginId: { type: 'string', optional: true }, + }, + }, + + YtdlpSearchResult: { + description: 'A YouTube search result from yt-dlp.', + fields: { + id: { type: 'string', description: 'YouTube video ID' }, + title: { type: 'string' }, + duration: { type: 'number | null' }, + thumbnail: { type: 'string | null' }, + }, + }, + + YtdlpStreamInfo: { + description: 'A resolved YouTube audio stream from yt-dlp.', + fields: { + stream_url: { type: 'string' }, + duration: { type: 'number | null' }, + title: { type: 'string | null' }, + }, + }, + + QueueItemStateUpdate: { + description: 'Partial update for a queue item status.', + fields: { + status: { + type: '"idle" | "loading" | "success" | "error"', + optional: true, + }, + error: { type: 'string', optional: true }, + }, + }, +}; diff --git a/packages/plugin-sdk/src/types/settings.ts b/packages/plugin-sdk/src/types/settings.ts index 3e297010..6b9c1630 100644 --- a/packages/plugin-sdk/src/types/settings.ts +++ b/packages/plugin-sdk/src/types/settings.ts @@ -17,7 +17,8 @@ export type NumberWidget = export type StringWidget = | { type: 'text'; placeholder?: string } | { type: 'password'; placeholder?: string } - | { type: 'textarea'; placeholder?: string; rows?: number }; + | { type: 'textarea'; placeholder?: string; rows?: number } + | { type: 'info' }; export type EnumWidget = { type: 'select' } | { type: 'radio' }; export type BooleanSettingDefinition = { diff --git a/packages/storybook/.storybook/preview.ts b/packages/storybook/.storybook/preview.ts index 174de195..e5fc9e97 100644 --- a/packages/storybook/.storybook/preview.ts +++ b/packages/storybook/.storybook/preview.ts @@ -3,6 +3,7 @@ import type { Preview } from '@storybook/react-vite'; import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/700.css'; import '@fontsource/bricolage-grotesque/800.css'; +import '@fontsource/space-mono/400.css'; import '@nuclearplayer/tailwind-config'; const preview: Preview = { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 75c0d74a..da7de317 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -13,6 +13,7 @@ "dependencies": { "@fontsource/bricolage-grotesque": "^5.2.8", "@fontsource/dm-sans": "^5.2.6", + "@fontsource/space-mono": "^5.2.9", "@nuclearplayer/model": "workspace:*", "@nuclearplayer/tailwind-config": "workspace:*", "@nuclearplayer/ui": "workspace:*", @@ -22,17 +23,17 @@ "storybook": "^9.1.5" }, "devDependencies": { + "@storybook/addon-docs": "^9.1.5", "@storybook/addon-links": "^9.1.5", "@storybook/react-vite": "^9.1.5", "@tailwindcss/vite": "^4.1.11", "@types/react": "^18.3.23", "@vitejs/plugin-react": "^5.0.1", + "eslint-plugin-storybook": "9.1.5", "sonner": "^2.0.7", "tailwindcss": "^4.1.11", "typescript": "^5.9.2", "vite": "^7.1.3", - "vite-plugin-svgr": "^4.3.0", - "eslint-plugin-storybook": "9.1.5", - "@storybook/addon-docs": "^9.1.5" + "vite-plugin-svgr": "^4.3.0" } } \ No newline at end of file diff --git a/packages/tailwind-config/global.css b/packages/tailwind-config/global.css index b09add00..1902991e 100644 --- a/packages/tailwind-config/global.css +++ b/packages/tailwind-config/global.css @@ -40,6 +40,7 @@ --font-sans: var(--font-family); --font-heading: var(--font-family-heading); + --font-mono: var(--font-family-mono); --shadow: var(--shadow-x) var(--shadow-y) 0px 0px var(--border); --shadow-shadow: var(--shadow); @@ -88,6 +89,9 @@ --font-family: 'DM Sans', system-ui, -apple-system, sans-serif; --default-font-family: 'DM Sans', system-ui, -apple-system, sans-serif; --font-family-heading: 'Bricolage Grotesque', var(--default-font-family); + --font-family-mono: + 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; --font-weight-normal: 400; --font-weight-bold: 700; --font-weight-extra-bold: 800; diff --git a/packages/ui/package.json b/packages/ui/package.json index 30e8eed0..fb5d189e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,6 +33,7 @@ "@floating-ui/react-dom": "^2.1.7", "@fontsource/bricolage-grotesque": "^5.2.8", "@fontsource/dm-sans": "^5.2.6", + "@fontsource/space-mono": "^5.2.9", "@headlessui/react": "^2.2.7", "@nuclearplayer/eslint-config": "workspace:*", "@nuclearplayer/model": "workspace:*", diff --git a/packages/ui/src/components/CopyButton/CopyButton.test.tsx b/packages/ui/src/components/CopyButton/CopyButton.test.tsx new file mode 100644 index 00000000..361afc97 --- /dev/null +++ b/packages/ui/src/components/CopyButton/CopyButton.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CopyButton } from './CopyButton'; + +describe('CopyButton', () => { + it('(Snapshot) renders default state', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('copies text to clipboard when clicked', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(() => Promise.resolve()); + vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(writeText); + + render(); + + await user.click(screen.getByTestId('copy-btn')); + + expect(writeText).toHaveBeenCalledWith('copy me'); + }); + + it('switches to check icon after copying', async () => { + const user = userEvent.setup(); + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + + render(); + + const button = screen.getByTestId('copy-btn'); + const iconBefore = button.querySelector('svg')!.innerHTML; + + await user.click(button); + + const iconAfter = button.querySelector('svg')!.innerHTML; + expect(iconAfter).not.toBe(iconBefore); + }); +}); diff --git a/packages/ui/src/components/LogEntry/CopyButton.tsx b/packages/ui/src/components/CopyButton/CopyButton.tsx similarity index 71% rename from packages/ui/src/components/LogEntry/CopyButton.tsx rename to packages/ui/src/components/CopyButton/CopyButton.tsx index cebb3ccc..a628258c 100644 --- a/packages/ui/src/components/LogEntry/CopyButton.tsx +++ b/packages/ui/src/components/CopyButton/CopyButton.tsx @@ -1,15 +1,22 @@ import { Check, Copy } from 'lucide-react'; -import { FC, memo, useEffect, useRef, useState } from 'react'; +import { ComponentProps, FC, memo, useEffect, useRef, useState } from 'react'; import { Button } from '../Button'; const COPY_FEEDBACK_DURATION_MS = 2000; -type CopyButtonProps = { +export type CopyButtonProps = Omit< + ComponentProps, + 'onClick' | 'children' +> & { text: string; }; -const CopyButtonImpl: FC = ({ text }) => { +const CopyButtonImpl: FC = ({ + text, + size = 'icon-sm', + ...props +}) => { const [copied, setCopied] = useState(false); const timerRef = useRef>(); @@ -33,7 +40,7 @@ const CopyButtonImpl: FC = ({ text }) => { }; return ( - ); diff --git a/packages/ui/src/components/CopyButton/__snapshots__/CopyButton.test.tsx.snap b/packages/ui/src/components/CopyButton/__snapshots__/CopyButton.test.tsx.snap new file mode 100644 index 00000000..4e64dbc1 --- /dev/null +++ b/packages/ui/src/components/CopyButton/__snapshots__/CopyButton.test.tsx.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CopyButton > (Snapshot) renders default state 1`] = ` + +`; diff --git a/packages/ui/src/components/CopyButton/index.ts b/packages/ui/src/components/CopyButton/index.ts new file mode 100644 index 00000000..19ab7e8e --- /dev/null +++ b/packages/ui/src/components/CopyButton/index.ts @@ -0,0 +1,2 @@ +export type { CopyButtonProps } from './CopyButton'; +export { CopyButton } from './CopyButton'; diff --git a/packages/ui/src/components/LogEntry/LogEntry.tsx b/packages/ui/src/components/LogEntry/LogEntry.tsx index cac54b9e..c169557c 100644 --- a/packages/ui/src/components/LogEntry/LogEntry.tsx +++ b/packages/ui/src/components/LogEntry/LogEntry.tsx @@ -5,7 +5,7 @@ import { ComponentProps, FC, memo } from 'react'; import { useCollapsibleText } from '../../hooks'; import { cn } from '../../utils'; -import { CopyButton } from './CopyButton'; +import { CopyButton } from '../CopyButton'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -164,7 +164,7 @@ const LogEntryImpl: FC = ({ data-testid="log-action-panel" className="absolute top-1 right-2 hidden group-hover:block" > - + ); diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 376a040f..2be55ec5 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -4,6 +4,7 @@ export * from './Box'; export * from './Button'; export * from './Card'; export * from './CardsRow'; +export * from './CopyButton'; export * from './Dialog'; export * from './FavoriteButton'; export * from './CardGrid'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 002fecb2..fa2a5f6d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,6 +2,7 @@ import '@nuclearplayer/tailwind-config'; import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/700.css'; import '@fontsource/bricolage-grotesque/800.css'; +import '@fontsource/space-mono/400.css'; export * from './components'; export * from './hooks'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ff8350e..2bea0ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: '@fontsource/dm-sans': specifier: ^5.2.6 version: 5.2.6 + '@fontsource/space-mono': + specifier: ^5.2.9 + version: 5.2.9 '@nuclearplayer/model': specifier: workspace:* version: link:../model @@ -637,6 +640,9 @@ importers: '@fontsource/dm-sans': specifier: ^5.2.6 version: 5.2.6 + '@fontsource/space-mono': + specifier: ^5.2.9 + version: 5.2.9 '@headlessui/react': specifier: ^2.2.7 version: 2.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1343,6 +1349,9 @@ packages: '@fontsource/dm-sans@5.2.6': resolution: {integrity: sha512-3AmJcX93hqmevsC2ixNwHj3iO3sNY/YHBqGhg0MjIyfhk6aZLNv1s2HqJ6xf8yf1rXXK3yR56rzyA3O21htKkg==} + '@fontsource/space-mono@5.2.9': + resolution: {integrity: sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg==} + '@headlessui/react@2.2.7': resolution: {integrity: sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==} engines: {node: '>=10'} @@ -7000,6 +7009,8 @@ snapshots: '@fontsource/dm-sans@5.2.6': {} + '@fontsource/space-mono@5.2.9': {} + '@headlessui/react@2.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)