๐ŸŽง The official command-line interface for Rocksky โ€” a modern, decentralized music tracking and discovery platform built on the AT Protocol.

implement Rocksky MCP server

+1100 -3
+226
README.md
··· 8 8 - ๐Ÿ“ˆ See your recent scrobbles 9 9 - ๐Ÿ“ค Manually scrobble tracks 10 10 - ๐Ÿ› ๏ธ Useful developer tools for integrating Rocksky into your workflows 11 + - ๐Ÿค– MCP Server 12 + 13 + ## Table of Contents 14 + - [Installation](#installation) 15 + - [Run in development](#run-in-development) 16 + - [Usage](#usage) 17 + - [Available Commands](#available-commands) 18 + - [login](#login) 19 + - [nowplaying](#nowplaying) 20 + - [scrobbles](#scrobbles) 21 + - [search](#search) 22 + - [stats](#stats) 23 + - [artists](#artists) 24 + - [albums](#albums) 25 + - [tracks](#tracks) 26 + - [scrobble](#scrobble) 27 + - [mcp](#mcp) 28 + - [Rocksky MCP Server Tools](#rocksky-mcp-server-tools) 29 + 11 30 12 31 ## Installation 13 32 ··· 96 115 ```bash 97 116 rocksky scrobble "Karma Police" "Radiohead" 98 117 ``` 118 + 119 + `whoami` - Displays the current user's information. 120 + 121 + ```bash 122 + rocksky whoami 123 + ``` 124 + 125 + `mcp` - Starts the Rocksky MCP server. 126 + 127 + ```bash 128 + rocksky mcp 129 + ``` 130 + 131 + ## Rocksky MCP Server Tools 132 + 133 + Here is a list of tools provided by the Rocksky MCP server: 134 + 135 + ### whoami 136 + 137 + Get the current user's information. 138 + 139 + ### nowplaying 140 + 141 + Get the currently playing track. 142 + 143 + **Parameters:** 144 + 145 + - `did` (optional): The DID or handle of the user to get the now playing track for. If not provided, it defaults to the current user. 146 + 147 + **Example:** 148 + ```json 149 + { 150 + "name": "nowplaying", 151 + "args": { 152 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 153 + } 154 + } 155 + ``` 156 + 157 + **Returns:** 158 + The currently playing track for the specified user. 159 + 160 + ### scrobbles 161 + 162 + Display recently played tracks (recent scrobbles). 163 + 164 + **Parameters:** 165 + - `did` (optional): The DID or handle of the user to get scrobbles for. If not provided, it returns all recent scrobbles from Rocksky. 166 + 167 + **Example:** 168 + ```json 169 + { 170 + "name": "scrobbles", 171 + "args": { 172 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 173 + } 174 + } 175 + ``` 176 + 177 + **Returns:** 178 + A list of recently played tracks for the specified user. 179 + 180 + ### my-scrobbles 181 + 182 + Display recently played tracks (recent scrobbles) for the current user. 183 + 184 + **Example:** 185 + ```json 186 + { 187 + "name": "my-scrobbles" 188 + } 189 + ``` 190 + 191 + **Returns:** 192 + A list of recently played tracks for the current user. 193 + 194 + ### search 195 + Search for tracks, albums, artists, or Rocksky users. 196 + 197 + **Parameters:** 198 + - `query`: The search query string. 199 + - `limit` (optional): The maximum number of results to return. Defaults to 10. 200 + - `albums` (optional): If true, search for albums. Defaults to false. 201 + - `artists` (optional): If true, search for artists. Defaults to false. 202 + - `tracks` (optional): If true, search for tracks. Defaults to false. 203 + - `users` (optional): If true, search for Rocksky users. Defaults to false. 204 + 205 + **Example:** 206 + ```json 207 + { 208 + "name": "search", 209 + "args": { 210 + "query": "Radiohead", 211 + "limit": 5, 212 + "albums": false, 213 + "artists": false, 214 + "tracks": false, 215 + "users": false 216 + } 217 + } 218 + ``` 219 + 220 + **Returns:** 221 + A list of search results based on the specified query and filters. 222 + 223 + ### artists 224 + List the user's top artists or current user's top artists if no `did` is provided. 225 + 226 + **Parameters:** 227 + - `did` (optional): The DID or handle of the user to get top artists for. If not provided, it defaults to the current user. 228 + - `limit` (optional): The maximum number of artists to return. Defaults to 20. 229 + 230 + **Example:** 231 + ```json 232 + { 233 + "name": "artists", 234 + "args": { 235 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 236 + "limit": 20 237 + } 238 + } 239 + ``` 240 + 241 + **Returns:** 242 + A list of the user's top artists, including their names and play counts. 243 + 244 + ### albums 245 + List the user's top albums or current user's top albums if no `did` is provided. 246 + 247 + **Parameters:** 248 + - `did` (optional): The DID or handle of the user to get top albums for. If not provided, it defaults to the current user. 249 + - `limit` (optional): The maximum number of albums to return. Defaults to 20. 250 + 251 + **Example:** 252 + ```json 253 + { 254 + "name": "albums", 255 + "args": { 256 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 257 + "limit": 20 258 + } 259 + } 260 + ``` 261 + 262 + **Returns:** 263 + A list of the user's top albums, including their names and play counts. 264 + 265 + ### tracks 266 + List the user's top tracks or current user's top tracks if no `did` is provided. 267 + 268 + **Parameters:** 269 + - `did` (optional): The DID or handle of the user to get top tracks for. If not provided, it defaults to the current user. 270 + - `limit` (optional): The maximum number of tracks to return. Defaults to 20. 271 + 272 + **Example:** 273 + ```json 274 + { 275 + "name": "tracks", 276 + "args": { 277 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 278 + "limit": 20 279 + } 280 + } 281 + ``` 282 + 283 + **Returns:** 284 + A list of the user's top tracks, including their names and play counts. 285 + 286 + ### stats 287 + Display the user's Rocksky account statistics or current user's statistics if no `did` is provided. 288 + 289 + **Parameters:** 290 + - `did` (optional): The DID or handle of the user to get statistics for. If not provided, it defaults to the current user. 291 + 292 + **Example:** 293 + ```json 294 + { 295 + "name": "stats", 296 + "args": { 297 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 298 + } 299 + } 300 + ``` 301 + 302 + ### create-apikey 303 + Create a new API key for the current user. 304 + 305 + **Parameters:** 306 + - `name`: The name of the API key. 307 + - `description` (optional): A description of the API key. 308 + 309 + **Example:** 310 + ```json 311 + { 312 + "name": "create-apikey", 313 + "args": { 314 + "name": "My API Key", 315 + "description": "This is my API key." 316 + } 317 + } 318 + ``` 319 + 320 + **Returns:** 321 + A confirmation message indicating that the API key was created successfully. 322 + 323 + 324 +
+194
TOOLS.md
··· 1 + # Rocksky MCP Tools 2 + 3 + This document provides a comprehensive list of all tools available in the Rocksky MCP server. 4 + 5 + ## whoami 6 + 7 + Get the current user's information. 8 + 9 + ## nowplaying 10 + 11 + Get the currently playing track. 12 + 13 + **Parameters:** 14 + 15 + - `did` (optional): The DID or handle of the user to get the now playing track for. If not provided, it defaults to the current user. 16 + 17 + **Example:** 18 + ```json 19 + { 20 + "name": "nowplaying", 21 + "args": { 22 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 23 + } 24 + } 25 + ``` 26 + 27 + **Returns:** 28 + The currently playing track for the specified user. 29 + 30 + ## scrobbles 31 + 32 + Display recently played tracks (recent scrobbles). 33 + 34 + **Parameters:** 35 + - `did` (optional): The DID or handle of the user to get scrobbles for. If not provided, it returns all recent scrobbles from Rocksky. 36 + 37 + **Example:** 38 + ```json 39 + { 40 + "name": "scrobbles", 41 + "args": { 42 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 43 + } 44 + } 45 + ``` 46 + 47 + **Returns:** 48 + A list of recently played tracks for the specified user. 49 + 50 + ## my-scrobbles 51 + 52 + Display recently played tracks (recent scrobbles) for the current user. 53 + 54 + **Example:** 55 + ```json 56 + { 57 + "name": "my-scrobbles" 58 + } 59 + ``` 60 + 61 + **Returns:** 62 + A list of recently played tracks for the current user. 63 + 64 + ## search 65 + Search for tracks, albums, artists, or Rocksky users. 66 + 67 + **Parameters:** 68 + - `query`: The search query string. 69 + - `limit` (optional): The maximum number of results to return. Defaults to 10. 70 + - `albums` (optional): If true, search for albums. Defaults to false. 71 + - `artists` (optional): If true, search for artists. Defaults to false. 72 + - `tracks` (optional): If true, search for tracks. Defaults to false. 73 + - `users` (optional): If true, search for Rocksky users. Defaults to false. 74 + 75 + **Example:** 76 + ```json 77 + { 78 + "name": "search", 79 + "args": { 80 + "query": "Radiohead", 81 + "limit": 5, 82 + "albums": false, 83 + "artists": false, 84 + "tracks": false, 85 + "users": false 86 + } 87 + } 88 + ``` 89 + 90 + **Returns:** 91 + A list of search results based on the specified query and filters. 92 + 93 + ## artists 94 + List the user's top artists or current user's top artists if no `did` is provided. 95 + 96 + **Parameters:** 97 + - `did` (optional): The DID or handle of the user to get top artists for. If not provided, it defaults to the current user. 98 + - `limit` (optional): The maximum number of artists to return. Defaults to 20. 99 + 100 + **Example:** 101 + ```json 102 + { 103 + "name": "artists", 104 + "args": { 105 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 106 + "limit": 20 107 + } 108 + } 109 + ``` 110 + 111 + **Returns:** 112 + A list of the user's top artists, including their names and play counts. 113 + 114 + ## albums 115 + List the user's top albums or current user's top albums if no `did` is provided. 116 + 117 + **Parameters:** 118 + - `did` (optional): The DID or handle of the user to get top albums for. If not provided, it defaults to the current user. 119 + - `limit` (optional): The maximum number of albums to return. Defaults to 20. 120 + 121 + **Example:** 122 + ```json 123 + { 124 + "name": "albums", 125 + "args": { 126 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 127 + "limit": 20 128 + } 129 + } 130 + ``` 131 + 132 + **Returns:** 133 + A list of the user's top albums, including their names and play counts. 134 + 135 + ## tracks 136 + List the user's top tracks or current user's top tracks if no `did` is provided. 137 + 138 + **Parameters:** 139 + - `did` (optional): The DID or handle of the user to get top tracks for. If not provided, it defaults to the current user. 140 + - `limit` (optional): The maximum number of tracks to return. Defaults to 20. 141 + 142 + **Example:** 143 + ```json 144 + { 145 + "name": "tracks", 146 + "args": { 147 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 148 + "limit": 20 149 + } 150 + } 151 + ``` 152 + 153 + **Returns:** 154 + A list of the user's top tracks, including their names and play counts. 155 + 156 + ## stats 157 + Display the user's Rocksky account statistics or current user's statistics if no `did` is provided. 158 + 159 + **Parameters:** 160 + - `did` (optional): The DID or handle of the user to get statistics for. If not provided, it defaults to the current user. 161 + 162 + **Example:** 163 + ```json 164 + { 165 + "name": "stats", 166 + "args": { 167 + "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr" 168 + } 169 + } 170 + ``` 171 + 172 + ## create-apikey 173 + Create a new API key for the current user. 174 + 175 + **Parameters:** 176 + - `name`: The name of the API key. 177 + - `description` (optional): A description of the API key. 178 + 179 + **Example:** 180 + ```json 181 + { 182 + "name": "create-apikey", 183 + "args": { 184 + "name": "My API Key", 185 + "description": "This is my API key." 186 + } 187 + } 188 + ``` 189 + 190 + **Returns:** 191 + A confirmation message indicating that the API key was created successfully. 192 + 193 + 194 +
+28
bun.lock
··· 4 4 "": { 5 5 "name": "rocksky", 6 6 "dependencies": { 7 + "@modelcontextprotocol/sdk": "^1.10.2", 7 8 "axios": "^1.8.4", 8 9 "chalk": "^5.4.1", 9 10 "commander": "^13.1.0", ··· 13 14 "md5": "^2.3.0", 14 15 "open": "^10.1.0", 15 16 "table": "^6.9.0", 17 + "zod": "^3.24.3", 16 18 }, 17 19 "devDependencies": { 18 20 "@types/express": "^5.0.1", ··· 75 77 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="], 76 78 77 79 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], 80 + 81 + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.10.2", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA=="], 78 82 79 83 "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 80 84 ··· 214 218 215 219 "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], 216 220 221 + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 222 + 217 223 "crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="], 218 224 219 225 "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], ··· 256 262 257 263 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 258 264 265 + "eventsource": ["eventsource@3.0.6", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA=="], 266 + 267 + "eventsource-parser": ["eventsource-parser@3.0.1", "", {}, "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA=="], 268 + 259 269 "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], 270 + 271 + "express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="], 260 272 261 273 "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 262 274 ··· 331 343 "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], 332 344 333 345 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 346 + 347 + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 334 348 335 349 "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], 336 350 ··· 370 384 371 385 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 372 386 387 + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 388 + 373 389 "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 374 390 375 391 "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], 376 392 377 393 "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], 394 + 395 + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], 378 396 379 397 "pkgroll": ["pkgroll@2.12.1", "", { "dependencies": { "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-dynamic-import-vars": "^2.1.5", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/pluginutils": "^5.1.4", "esbuild": "^0.25.1", "magic-string": "^0.30.17", "rollup": "^4.34.6", "rollup-pluginutils": "^2.8.2" }, "peerDependencies": { "typescript": "^4.1 || ^5.0" }, "optionalPeers": ["typescript"], "bin": { "pkgroll": "dist/cli.mjs" } }, "sha512-MpooedkVk28Sl1I5q8YO2QZmdlHdEtCzv1nReZdHNRhY0CzbZ14TewN47JopF+0rGCaQOERSPfcIOgPZDQXlZA=="], 380 398 ··· 418 436 419 437 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 420 438 439 + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 440 + 441 + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 442 + 421 443 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], 422 444 423 445 "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], ··· 454 476 455 477 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], 456 478 479 + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 480 + 457 481 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 482 + 483 + "zod": ["zod@3.24.3", "", {}, "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg=="], 484 + 485 + "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], 458 486 459 487 "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 460 488
+4 -2
package.json
··· 1 1 { 2 2 "name": "@rocksky/cli", 3 - "version": "0.1.1", 3 + "version": "0.2.0", 4 4 "description": "Command-line interface for Rocksky โ€“ scrobble tracks, view stats, and manage your listening history", 5 5 "main": "dist/index.js", 6 6 "type": "module", ··· 22 22 "author": "Tsiry Sandratraina <tsiry.sndr@rocksky.app>", 23 23 "license": "Apache-2.0", 24 24 "dependencies": { 25 + "@modelcontextprotocol/sdk": "^1.10.2", 25 26 "axios": "^1.8.4", 26 27 "chalk": "^5.4.1", 27 28 "commander": "^13.1.0", ··· 30 31 "express": "^5.1.0", 31 32 "md5": "^2.3.0", 32 33 "open": "^10.1.0", 33 - "table": "^6.9.0" 34 + "table": "^6.9.0", 35 + "zod": "^3.24.3" 34 36 }, 35 37 "devDependencies": { 36 38 "@types/express": "^5.0.1",
+8
src/cmd/mcp.ts
··· 1 + import { rockskyMcpServer } from "mcp"; 2 + 3 + export function mcp() { 4 + rockskyMcpServer.run().catch((error) => { 5 + console.error("Failed to run Rocksky MCP server", { error }); 6 + process.exit(1); 7 + }); 8 + }
+10 -1
src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 3 + import chalk from "chalk"; 3 4 import { albums } from "cmd/albums"; 4 5 import { artists } from "cmd/artists"; 5 6 import { createApiKey } from "cmd/create"; 7 + import { mcp } from "cmd/mcp"; 6 8 import { nowplaying } from "cmd/nowplaying"; 7 9 import { scrobble } from "cmd/scrobble"; 8 10 import { scrobbles } from "cmd/scrobbles"; ··· 19 21 program 20 22 .name("rocksky") 21 23 .description( 22 - "Command-line interface for Rocksky โ€“ scrobble tracks, view stats, and manage your listening history." 24 + `Command-line interface for Rocksky (${chalk.underline( 25 + "https://rocksky.app" 26 + )}) โ€“ scrobble tracks, view stats, and manage your listening history.` 23 27 ) 24 28 .version(version.version); 25 29 ··· 108 112 .option("-d, --description <description>", "the description of the API key") 109 113 .description("create a new API key.") 110 114 .action(createApiKey); 115 + 116 + program 117 + .command("mcp") 118 + .description("Starts an MCP server to use with Claude or other LLMs.") 119 + .action(mcp); 111 120 112 121 program.parse(process.argv);
+269
src/mcp/index.ts
··· 1 + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 + import { RockskyClient } from "client"; 4 + import { z } from "zod"; 5 + import { albums } from "./tools/albums"; 6 + import { artists } from "./tools/artists"; 7 + import { createApiKey } from "./tools/create"; 8 + import { myscrobbles } from "./tools/myscrobbles"; 9 + import { nowplaying } from "./tools/nowplaying"; 10 + import { scrobbles } from "./tools/scrobbles"; 11 + import { search } from "./tools/search"; 12 + import { stats } from "./tools/stats"; 13 + import { tracks } from "./tools/tracks"; 14 + import { whoami } from "./tools/whoami"; 15 + 16 + class RockskyMcpServer { 17 + private readonly server: McpServer; 18 + private readonly client: RockskyClient; 19 + 20 + constructor() { 21 + this.server = new McpServer({ 22 + name: "rocksky-mcp", 23 + version: "0.1.0", 24 + }); 25 + const client = new RockskyClient(); 26 + this.setupTools(); 27 + } 28 + 29 + private setupTools() { 30 + this.server.tool("whoami", "get the current logged-in user.", async () => { 31 + return { 32 + content: [ 33 + { 34 + type: "text", 35 + text: await whoami(), 36 + }, 37 + ], 38 + }; 39 + }); 40 + 41 + this.server.tool( 42 + "nowplaying", 43 + "get the currently playing track.", 44 + { 45 + did: z 46 + .string() 47 + .optional() 48 + .describe( 49 + "the DID or handle of the user to get the now playing track for." 50 + ), 51 + }, 52 + async ({ did }) => { 53 + return { 54 + content: [ 55 + { 56 + type: "text", 57 + text: await nowplaying(did), 58 + }, 59 + ], 60 + }; 61 + } 62 + ); 63 + 64 + this.server.tool( 65 + "scrobbles", 66 + "display recently played tracks (recent scrobbles).", 67 + { 68 + did: z 69 + .string() 70 + .optional() 71 + .describe("the DID or handle of the user to get the scrobbles for."), 72 + skip: z.number().optional().describe("number of scrobbles to skip"), 73 + limit: z.number().optional().describe("number of scrobbles to limit"), 74 + }, 75 + async ({ did, skip = 0, limit = 10 }) => { 76 + return { 77 + content: [ 78 + { 79 + type: "text", 80 + text: await scrobbles(did, { skip, limit }), 81 + }, 82 + ], 83 + }; 84 + } 85 + ); 86 + 87 + this.server.tool( 88 + "my-scrobbles", 89 + "display my recently played tracks (recent scrobbles).", 90 + { 91 + skip: z.number().optional().describe("number of scrobbles to skip"), 92 + limit: z.number().optional().describe("number of scrobbles to limit"), 93 + }, 94 + async ({ skip = 0, limit = 10 }) => { 95 + return { 96 + content: [ 97 + { 98 + type: "text", 99 + text: await myscrobbles({ skip, limit }), 100 + }, 101 + ], 102 + }; 103 + } 104 + ); 105 + 106 + this.server.tool( 107 + "search", 108 + "search for tracks, artists, albums or users.", 109 + { 110 + query: z 111 + .string() 112 + .describe("the search query, e.g., artist, album, title or account"), 113 + limit: z.number().optional().describe("number of results to limit"), 114 + albums: z.boolean().optional().describe("search for albums"), 115 + tracks: z.boolean().optional().describe("search for tracks"), 116 + users: z.boolean().optional().describe("search for users"), 117 + artists: z.boolean().optional().describe("search for artists"), 118 + }, 119 + async ({ 120 + query, 121 + limit = 10, 122 + albums = false, 123 + tracks = false, 124 + users = false, 125 + artists = false, 126 + }) => { 127 + return { 128 + content: [ 129 + { 130 + type: "text", 131 + text: await search(query, { 132 + limit, 133 + albums, 134 + tracks, 135 + users, 136 + artists, 137 + }), 138 + }, 139 + ], 140 + }; 141 + } 142 + ); 143 + 144 + this.server.tool( 145 + "artists", 146 + "get the user's top artists or current user's artists if no did is provided.", 147 + { 148 + did: z 149 + .string() 150 + .optional() 151 + .describe("the DID or handle of the user to get artists for."), 152 + 153 + limit: z.number().optional().describe("number of results to limit"), 154 + }, 155 + async ({ did, limit }) => { 156 + return { 157 + content: [ 158 + { 159 + type: "text", 160 + text: await artists(did, { skip: 0, limit }), 161 + }, 162 + ], 163 + }; 164 + } 165 + ); 166 + 167 + this.server.tool( 168 + "albums", 169 + "get the user's top albums or current user's albums if no did is provided.", 170 + { 171 + did: z 172 + .string() 173 + .optional() 174 + .describe("the DID or handle of the user to get albums for."), 175 + limit: z.number().optional().describe("number of results to limit"), 176 + }, 177 + async ({ did, limit }) => { 178 + return { 179 + content: [ 180 + { 181 + type: "text", 182 + text: await albums(did, { skip: 0, limit }), 183 + }, 184 + ], 185 + }; 186 + } 187 + ); 188 + 189 + this.server.tool( 190 + "tracks", 191 + "get the user's top tracks or current user's tracks if no did is provided.", 192 + { 193 + did: z 194 + .string() 195 + .optional() 196 + .describe("the DID or handle of the user to get tracks for."), 197 + limit: z.number().optional().describe("number of results to limit"), 198 + }, 199 + async ({ did, limit }) => { 200 + return { 201 + content: [ 202 + { 203 + type: "text", 204 + text: await tracks(did, { skip: 0, limit }), 205 + }, 206 + ], 207 + }; 208 + } 209 + ); 210 + 211 + this.server.tool( 212 + "stats", 213 + "get the user's listening stats or current user's stats if no did is provided.", 214 + { 215 + did: z 216 + .string() 217 + .optional() 218 + .describe("the DID or handle of the user to get stats for."), 219 + }, 220 + async ({ did }) => { 221 + return { 222 + content: [ 223 + { 224 + type: "text", 225 + text: await stats(did), 226 + }, 227 + ], 228 + }; 229 + } 230 + ); 231 + 232 + this.server.tool( 233 + "create-apikey", 234 + "create an API key.", 235 + { 236 + name: z.string().describe("the name of the API key"), 237 + description: z 238 + .string() 239 + .optional() 240 + .describe("the description of the API key"), 241 + }, 242 + async ({ name, description }) => { 243 + return { 244 + content: [ 245 + { 246 + type: "text", 247 + text: await createApiKey(name, { description }), 248 + }, 249 + ], 250 + }; 251 + } 252 + ); 253 + } 254 + 255 + async run() { 256 + const stdioTransport = new StdioServerTransport(); 257 + try { 258 + await this.server.connect(stdioTransport); 259 + } catch (error) { 260 + process.exit(1); 261 + } 262 + } 263 + 264 + public getServer(): McpServer { 265 + return this.server; 266 + } 267 + } 268 + 269 + export const rockskyMcpServer = new RockskyMcpServer();
+13
src/mcp/tools/albums.ts
··· 1 + import { RockskyClient } from "client"; 2 + 3 + export async function albums(did, { skip, limit = 20 }): Promise<string> { 4 + const client = new RockskyClient(); 5 + const albums = await client.getAlbums(did, { skip, limit }); 6 + let rank = 1; 7 + let response = `Top ${limit} albums:\n`; 8 + for (const album of albums) { 9 + response += `${rank} ${album.title} - ${album.artist} - ${album.play_count} plays\n`; 10 + rank++; 11 + } 12 + return response; 13 + }
+17
src/mcp/tools/artists.ts
··· 1 + import { RockskyClient } from "client"; 2 + 3 + export async function artists(did, { skip, limit = 20 }): Promise<string> { 4 + try { 5 + const client = new RockskyClient(); 6 + const artists = await client.getArtists(did, { skip, limit }); 7 + let rank = 1; 8 + let response = `Top ${limit} artists:\n`; 9 + for (const artist of artists) { 10 + response += `${rank} ${artist.name} - ${artist.play_count} plays\n`; 11 + rank++; 12 + } 13 + return response; 14 + } catch (err) { 15 + return `Failed to fetch artists data. Please check your token and try again, error: ${err.message}`; 16 + } 17 + }
+27
src/mcp/tools/create.ts
··· 1 + import { RockskyClient } from "client"; 2 + import fs from "fs/promises"; 3 + import os from "os"; 4 + import path from "path"; 5 + 6 + export async function createApiKey(name, { description }) { 7 + const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); 8 + try { 9 + await fs.access(tokenPath); 10 + } catch (err) { 11 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 12 + } 13 + 14 + const tokenData = await fs.readFile(tokenPath, "utf-8"); 15 + const { token } = JSON.parse(tokenData); 16 + if (!token) { 17 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 18 + } 19 + 20 + const client = new RockskyClient(token); 21 + const apikey = await client.createApiKey(name, description); 22 + if (!apikey) { 23 + return "Failed to create API key. Please try again later."; 24 + } 25 + 26 + return "API key created successfully!, navigate to your Rocksky account to view it."; 27 + }
+42
src/mcp/tools/myscrobbles.ts
··· 1 + import { RockskyClient } from "client"; 2 + import dayjs from "dayjs"; 3 + import relative from "dayjs/plugin/relativeTime.js"; 4 + import fs from "fs/promises"; 5 + import os from "os"; 6 + import path from "path"; 7 + 8 + dayjs.extend(relative); 9 + 10 + export async function myscrobbles({ skip, limit }): Promise<string> { 11 + const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); 12 + try { 13 + await fs.access(tokenPath); 14 + } catch (err) { 15 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 16 + } 17 + 18 + const tokenData = await fs.readFile(tokenPath, "utf-8"); 19 + const { token } = JSON.parse(tokenData); 20 + if (!token) { 21 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 22 + } 23 + 24 + const client = new RockskyClient(token); 25 + try { 26 + const { did } = await client.getCurrentUser(); 27 + const scrobbles = await client.scrobbles(did, { skip, limit }); 28 + 29 + return JSON.stringify( 30 + scrobbles.map((scrobble) => ({ 31 + title: scrobble.title, 32 + artist: scrobble.artist, 33 + date: dayjs(scrobble.created_at + "Z").fromNow(), 34 + isoDate: scrobble.created_at, 35 + })), 36 + null, 37 + 2 38 + ); 39 + } catch (err) { 40 + return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`; 41 + } 42 + }
+53
src/mcp/tools/nowplaying.ts
··· 1 + import { RockskyClient } from "client"; 2 + import fs from "fs/promises"; 3 + import os from "os"; 4 + import path from "path"; 5 + 6 + export async function nowplaying(did?: string): Promise<string> { 7 + const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); 8 + try { 9 + await fs.access(tokenPath); 10 + } catch (err) { 11 + if (!did) { 12 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 13 + } 14 + } 15 + 16 + const tokenData = await fs.readFile(tokenPath, "utf-8"); 17 + const { token } = JSON.parse(tokenData); 18 + if (!token && !did) { 19 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 20 + } 21 + 22 + const client = new RockskyClient(token); 23 + try { 24 + const nowPlaying = await client.getSpotifyNowPlaying(did); 25 + if (!nowPlaying || Object.keys(nowPlaying).length === 0) { 26 + const nowPlaying = await client.getNowPlaying(did); 27 + if (!nowPlaying || Object.keys(nowPlaying).length === 0) { 28 + return "No track is currently playing."; 29 + } 30 + return JSON.stringify( 31 + { 32 + title: nowPlaying.title, 33 + artist: nowPlaying.artist, 34 + album: nowPlaying.album, 35 + }, 36 + null, 37 + 2 38 + ); 39 + } 40 + 41 + return JSON.stringify( 42 + { 43 + title: nowPlaying.item.name, 44 + artist: nowPlaying.item.artists.map((a) => a.name).join(", "), 45 + album: nowPlaying.item.album.name, 46 + }, 47 + null, 48 + 2 49 + ); 50 + } catch (err) { 51 + return `Failed to fetch now playing data. Please check your token and try again, error: ${err.message}`; 52 + } 53 + }
+39
src/mcp/tools/scrobbles.ts
··· 1 + import { RockskyClient } from "client"; 2 + import dayjs from "dayjs"; 3 + import relative from "dayjs/plugin/relativeTime.js"; 4 + 5 + dayjs.extend(relative); 6 + 7 + export async function scrobbles(did, { skip, limit }): Promise<string> { 8 + try { 9 + const client = new RockskyClient(); 10 + const scrobbles = await client.scrobbles(did, { skip, limit }); 11 + 12 + if (did) { 13 + return JSON.stringify( 14 + scrobbles.map((scrobble) => ({ 15 + title: scrobble.title, 16 + artist: scrobble.artist, 17 + date: dayjs(scrobble.created_at + "Z").fromNow(), 18 + isoDate: scrobble.created_at, 19 + })), 20 + null, 21 + 2 22 + ); 23 + } 24 + 25 + return JSON.stringify( 26 + scrobbles.map((scrobble) => ({ 27 + user: `@${scrobble.user}`, 28 + title: scrobble.title, 29 + artist: scrobble.artist, 30 + date: dayjs(scrobble.date).fromNow(), 31 + isoDate: scrobble.date, 32 + })), 33 + null, 34 + 2 35 + ); 36 + } catch (err) { 37 + return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`; 38 + } 39 + }
+88
src/mcp/tools/search.ts
··· 1 + import { RockskyClient } from "client"; 2 + 3 + export async function search( 4 + query: string, 5 + { limit = 20, albums = false, artists = false, tracks = false, users = false } 6 + ): Promise<string> { 7 + const client = new RockskyClient(); 8 + const results = await client.search(query, { size: limit }); 9 + if (results.records.length === 0) { 10 + return `No results found for ${query}.`; 11 + } 12 + 13 + // merge all results into one array with type and sort by xata_scrore 14 + let mergedResults = results.records.map((record) => ({ 15 + ...record, 16 + type: record.table, 17 + })); 18 + 19 + if (albums) { 20 + mergedResults = mergedResults.filter((record) => record.table === "albums"); 21 + } 22 + 23 + if (artists) { 24 + mergedResults = mergedResults.filter( 25 + (record) => record.table === "artists" 26 + ); 27 + } 28 + 29 + if (tracks) { 30 + mergedResults = mergedResults.filter(({ table }) => table === "tracks"); 31 + } 32 + 33 + if (users) { 34 + mergedResults = mergedResults.filter(({ table }) => table === "users"); 35 + } 36 + 37 + mergedResults.sort((a, b) => b.xata_score - a.xata_score); 38 + 39 + const responses = []; 40 + for (const { table, record } of mergedResults) { 41 + if (table === "users") { 42 + responses.push({ 43 + handle: record.handle, 44 + display_name: record.display_name, 45 + did: record.did, 46 + link: `https://rocksky.app/profile/${record.did}`, 47 + type: "account", 48 + }); 49 + } 50 + 51 + if (table === "albums") { 52 + const link = record.uri 53 + ? `https://rocksky.app/${record.uri?.split("at://")[1]}` 54 + : ""; 55 + responses.push({ 56 + title: record.title, 57 + artist: record.artist, 58 + link: link, 59 + type: "album", 60 + }); 61 + } 62 + 63 + if (table === "tracks") { 64 + const link = record.uri 65 + ? `https://rocksky.app/${record.uri?.split("at://")[1]}` 66 + : ""; 67 + responses.push({ 68 + title: record.title, 69 + artist: record.artist, 70 + link: link, 71 + type: "track", 72 + }); 73 + } 74 + 75 + if (table === "artists") { 76 + const link = record.uri 77 + ? `https://rocksky.app/${record.uri?.split("at://")[1]}` 78 + : ""; 79 + responses.push({ 80 + name: record.name, 81 + link: link, 82 + type: "artist", 83 + }); 84 + } 85 + } 86 + 87 + return JSON.stringify(responses, null, 2); 88 + }
+40
src/mcp/tools/stats.ts
··· 1 + import { RockskyClient } from "client"; 2 + import fs from "fs/promises"; 3 + import os from "os"; 4 + import path from "path"; 5 + 6 + export async function stats(did?: string): Promise<string> { 7 + const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); 8 + try { 9 + await fs.access(tokenPath); 10 + } catch (err) { 11 + if (!did) { 12 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 13 + } 14 + } 15 + 16 + const tokenData = await fs.readFile(tokenPath, "utf-8"); 17 + const { token } = JSON.parse(tokenData); 18 + if (!token && !did) { 19 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 20 + } 21 + 22 + try { 23 + const client = new RockskyClient(token); 24 + const stats = await client.stats(did); 25 + 26 + return JSON.stringify( 27 + { 28 + scrobbles: stats.scrobbles, 29 + tracks: stats.tracks, 30 + albums: stats.albums, 31 + artists: stats.artists, 32 + lovedTracks: stats.lovedTracks, 33 + }, 34 + null, 35 + 2 36 + ); 37 + } catch (err) { 38 + return `Failed to fetch stats data. Please check your token and try again, error: ${err.message}`; 39 + } 40 + }
+15
src/mcp/tools/tracks.ts
··· 1 + import { RockskyClient } from "client"; 2 + 3 + export async function tracks(did, { skip, limit = 20 }) { 4 + const client = new RockskyClient(); 5 + const tracks = await client.getTracks(did, { skip, limit }); 6 + let rank = 1; 7 + let response = `Top ${limit} tracks:\n`; 8 + 9 + for (const track of tracks) { 10 + response += `${rank} ${track.title} - ${track.artist} - ${track.play_count} plays\n`; 11 + rank++; 12 + } 13 + 14 + return response; 15 + }
+27
src/mcp/tools/whoami.ts
··· 1 + import { RockskyClient } from "client"; 2 + import fs from "fs/promises"; 3 + import os from "os"; 4 + import path from "path"; 5 + 6 + export async function whoami(): Promise<string> { 7 + const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); 8 + try { 9 + await fs.access(tokenPath); 10 + } catch (err) { 11 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 12 + } 13 + 14 + const tokenData = await fs.readFile(tokenPath, "utf-8"); 15 + const { token } = JSON.parse(tokenData); 16 + if (!token) { 17 + return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; 18 + } 19 + 20 + const client = new RockskyClient(token); 21 + try { 22 + const user = await client.getCurrentUser(); 23 + return `You are logged in as ${user.handle} (${user.displayName}).\nView your profile at: https://rocksky.app/profile/${user.handle}`; 24 + } catch (err) { 25 + return "Failed to fetch user data. Please check your token and try again."; 26 + } 27 + }