@rocksky/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
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();
@@ -0,0 +1,13 @@
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
+ }
@@ -0,0 +1,17 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,53 @@
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
+ }
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,88 @@
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
+ }
@@ -0,0 +1,40 @@
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
+ }
@@ -0,0 +1,15 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ }