music on atproto
plyr.fm
1# neon mcp guide for plyr.fm
2
3the [neon mcp server](https://github.com/neondatabase/mcp-server-neon) provides tools for interacting with neon postgres databases through the model context protocol. this guide covers read-only operations useful for inspecting and debugging plyr.fm's database.
4
5## overview
6
7the neon mcp is integrated into claude code and provides direct access to:
8- project and branch management
9- database schema inspection
10- SQL query execution
11- connection string generation
12
13this guide focuses on **read-only operations** - querying and inspecting data without modifications.
14
15## discovering your projects
16
17### list all projects
18
19```json
20mcp__neon__list_projects({})
21```
22
23returns all neon projects in your account with details like:
24- project name and ID
25- region (aws-us-east-1, aws-us-east-2, etc.)
26- postgres version
27- compute usage stats
28- storage size
29
30**plyr.fm projects:**
31- `plyr-prd` (cold-butterfly-11920742) - production (us-east-1)
32- `plyr-stg` (frosty-math-37367092) - staging (us-west-2)
33- `plyr-dev` (muddy-flower-98795112) - development (us-east-2)
34
35### get project details
36
37```json
38mcp__neon__describe_project({
39 "projectId": "muddy-flower-98795112"
40})
41```
42
43shows branches, compute endpoints, and configuration for a specific project.
44
45## exploring database structure
46
47### list all tables
48
49```json
50mcp__neon__get_database_tables({
51 "projectId": "muddy-flower-98795112"
52})
53```
54
55**plyr.fm tables:**
56- `tracks` - uploaded music files with metadata
57- `artists` - user profiles (DIDs, handles, display names)
58- `track_likes` - like interactions between users and tracks
59- `user_sessions` - oauth session management
60- `oauth_states` - oauth flow state tracking
61- `exchange_tokens` - token exchange for auth
62- `user_preferences` - user settings
63- `queue_state` - processing queue status
64- `alembic_version` - database migration tracking
65
66### inspect table schema
67
68```json
69mcp__neon__describe_table_schema({
70 "projectId": "muddy-flower-98795112",
71 "tableName": "tracks"
72})
73```
74
75returns detailed schema information:
76- column names, types, nullable, defaults
77- indexes (btree, unique, etc.)
78- constraints (primary keys, foreign keys)
79- table sizes
80
81**key tracks columns:**
82- `id` (integer, primary key)
83- `title` (varchar, not null)
84- `file_id` (varchar, not null) - R2 storage key
85- `file_type` (varchar, not null) - audio format
86- `artist_did` (varchar, foreign key → artists)
87- `atproto_record_uri` (varchar, nullable) - ATProto record reference
88- `atproto_record_cid` (varchar, nullable) - content identifier
89- `play_count` (integer, default 0)
90- `features` (jsonb, default []) - collaborating artists
91- `image_id` (varchar, nullable) - album art reference
92- `extra` (jsonb, default {}) - additional metadata (album, etc.)
93
94**key artists columns:**
95- `did` (varchar, primary key) - ATProto decentralized identifier
96- `handle` (varchar, not null) - user handle
97- `display_name` (varchar, not null)
98- `pds_url` (varchar, nullable) - custom PDS endpoint
99
100### visualize database structure
101
102```json
103mcp__neon__describe_branch({
104 "projectId": "muddy-flower-98795112",
105 "branchId": "br-crimson-recipe-aesyo0p9"
106})
107```
108
109returns a tree view showing all databases, schemas, tables, indexes, functions, and sequences.
110
111## running queries
112
113### basic query execution
114
115```json
116mcp__neon__run_sql({
117 "projectId": "muddy-flower-98795112",
118 "sql": "SELECT COUNT(*) FROM tracks"
119})
120```
121
122**important notes:**
123- always provide `projectId`
124- queries run on the default branch unless `branchId` is specified
125- uses default database `neondb` unless `databaseName` is specified
126- results returned as JSON array of objects
127
128### useful plyr.fm queries
129
130#### overview stats
131
132```sql
133-- total tracks and artists
134SELECT
135 COUNT(*) as total_tracks,
136 COUNT(DISTINCT artist_did) as total_artists
137FROM tracks;
138
139-- atproto integration status
140SELECT
141 COUNT(*) FILTER (WHERE atproto_record_uri IS NOT NULL) as synced_tracks,
142 COUNT(*) FILTER (WHERE atproto_record_uri IS NULL) as unsynced_tracks,
143 COUNT(*) FILTER (WHERE image_id IS NOT NULL) as tracks_with_images,
144 COUNT(*) FILTER (WHERE image_id IS NULL) as tracks_without_images
145FROM tracks;
146
147-- engagement metrics
148SELECT
149 COUNT(*) as total_likes,
150 COUNT(DISTINCT user_did) as unique_likers,
151 COUNT(DISTINCT track_id) as liked_tracks
152FROM track_likes;
153
154-- storage stats by file type
155SELECT
156 file_type,
157 COUNT(*) as count,
158 COUNT(*) FILTER (WHERE image_id IS NOT NULL) as with_artwork
159FROM tracks
160GROUP BY file_type
161ORDER BY count DESC;
162```
163
164#### artist analytics
165
166```sql
167-- artist leaderboard by uploads
168SELECT
169 a.handle,
170 a.display_name,
171 COUNT(t.id) as track_count,
172 SUM(t.play_count) as total_plays,
173 a.pds_url
174FROM artists a
175LEFT JOIN tracks t ON a.did = t.artist_did
176GROUP BY a.did, a.handle, a.display_name, a.pds_url
177ORDER BY track_count DESC, total_plays DESC;
178
179-- tracks per artist
180SELECT
181 artist_did,
182 COUNT(*) as track_count
183FROM tracks
184GROUP BY artist_did
185ORDER BY track_count DESC;
186```
187
188#### track discovery
189
190```sql
191-- recent uploads
192SELECT
193 t.id,
194 t.title,
195 a.handle,
196 a.display_name,
197 t.play_count,
198 t.created_at,
199 t.atproto_record_uri IS NOT NULL as has_atproto_record,
200 t.image_id IS NOT NULL as has_image
201FROM tracks t
202JOIN artists a ON t.artist_did = a.did
203ORDER BY t.created_at DESC
204LIMIT 10;
205
206-- most popular tracks
207SELECT
208 t.id,
209 t.title,
210 a.handle,
211 t.play_count,
212 COUNT(tl.id) as like_count
213FROM tracks t
214JOIN artists a ON t.artist_did = a.did
215LEFT JOIN track_likes tl ON t.id = tl.track_id
216GROUP BY t.id, t.title, a.handle, t.play_count
217ORDER BY t.play_count DESC, like_count DESC
218LIMIT 10;
219
220-- tracks with album metadata
221SELECT
222 t.title,
223 t.extra->>'album' as album,
224 a.handle
225FROM tracks t
226JOIN artists a ON t.artist_did = a.did
227WHERE t.extra->>'album' IS NOT NULL;
228```
229
230#### time-series analysis
231
232```sql
233-- uploads per day
234SELECT
235 DATE_TRUNC('day', created_at) as day,
236 COUNT(*) as uploads
237FROM tracks
238GROUP BY day
239ORDER BY day DESC;
240
241-- engagement trends
242SELECT
243 DATE_TRUNC('day', tl.created_at) as day,
244 COUNT(*) as likes_given
245FROM track_likes tl
246GROUP BY day
247ORDER BY day DESC;
248```
249
250#### atproto integration debugging
251
252```sql
253-- tracks missing atproto records
254SELECT
255 t.id,
256 t.title,
257 t.artist_did,
258 a.handle,
259 a.pds_url,
260 t.created_at
261FROM tracks t
262JOIN artists a ON t.artist_did = a.did
263WHERE t.atproto_record_uri IS NULL
264ORDER BY t.created_at DESC
265LIMIT 20;
266
267-- verify atproto record URIs format
268SELECT
269 id,
270 title,
271 atproto_record_uri,
272 atproto_record_cid,
273 created_at
274FROM tracks
275WHERE atproto_record_uri IS NOT NULL
276ORDER BY created_at DESC
277LIMIT 10;
278
279-- check for uri/cid mismatches (uri present but cid missing or vice versa)
280SELECT
281 id,
282 title,
283 atproto_record_uri IS NOT NULL as has_uri,
284 atproto_record_cid IS NOT NULL as has_cid
285FROM tracks
286WHERE (atproto_record_uri IS NULL) != (atproto_record_cid IS NULL)
287LIMIT 20;
288```
289
290#### jsonb field queries
291
292```sql
293-- query features array (collaborations)
294SELECT
295 t.title,
296 a.handle,
297 jsonb_array_length(t.features) as feature_count,
298 t.features
299FROM tracks t
300JOIN artists a ON t.artist_did = a.did
301WHERE jsonb_array_length(t.features) > 0;
302
303-- query extra metadata
304SELECT
305 title,
306 extra->>'album' as album,
307 extra->>'genre' as genre,
308 extra
309FROM tracks
310WHERE extra != '{}'::jsonb;
311```
312
313## connection management
314
315### get connection string
316
317```json
318mcp__neon__get_connection_string({
319 "projectId": "muddy-flower-98795112"
320})
321```
322
323returns a postgres connection string for use with `psql`, database clients, or application configs.
324
325**optional parameters:**
326- `branchId` - specific branch (defaults to main)
327- `databaseName` - specific database (defaults to neondb)
328- `roleName` - specific role (defaults to neondb_owner)
329
330**example output:**
331```
332postgresql://neondb_owner:npg_6CNUVfgtz8bY@ep-flat-haze-aefjvcba-pooler.c-2.us-east-2.aws.neon.tech/neondb?channel_binding=require&sslmode=require
333```
334
335## database environment mapping
336
337plyr.fm uses different neon projects for each environment:
338
339| environment | project name | project ID | region | endpoint |
340|------------|--------------|-----------|---------|----------|
341| dev | plyr-dev | muddy-flower-98795112 | us-east-2 | ep-flat-haze-aefjvcba |
342| staging | plyr-staging | frosty-math-37367092 | us-west-2 | (varies) |
343| prod | plyr | cold-butterfly-11920742 | us-east-1 | ep-young-poetry-a4ueyq14 |
344
345**in .env:**
346- default `DATABASE_URL` points to dev (plyr-dev)
347- prod connection string is commented out
348- admin scripts use `ADMIN_DATABASE_URL` for prod operations
349
350## common workflows
351
352### debugging orphaned records
353
354```sql
355-- 1. check for tracks without atproto records
356SELECT COUNT(*) FROM tracks WHERE atproto_record_uri IS NULL;
357
358-- 2. identify which artists have orphaned tracks
359SELECT
360 a.handle,
361 COUNT(*) as orphaned_tracks
362FROM tracks t
363JOIN artists a ON t.artist_did = a.did
364WHERE t.atproto_record_uri IS NULL
365GROUP BY a.handle;
366
367-- 3. compare with pdsx output
368-- use pdsx to list records on PDS:
369-- uvx pdsx --pds https://pds.zzstoatzz.io -r zzstoatzz.io ls fm.plyr.track
370```
371
372### verifying backfill success
373
374after running `scripts/backfill_atproto_records.py`:
375
376```sql
377-- 1. count records with ATProto URIs
378SELECT
379 COUNT(*) FILTER (WHERE atproto_record_uri IS NOT NULL) as synced,
380 COUNT(*) FILTER (WHERE atproto_record_uri IS NULL) as unsynced
381FROM tracks;
382
383-- 2. check specific tracks mentioned in backfill
384SELECT
385 title,
386 atproto_record_uri,
387 atproto_record_cid
388FROM tracks
389WHERE title IN ('webhook', 'maxwell', 'ccr')
390ORDER BY created_at DESC;
391
392-- 3. verify image_id populated for tracks with artwork
393SELECT
394 title,
395 image_id IS NOT NULL as has_image,
396 atproto_record_uri
397FROM tracks
398WHERE title = 'ccr';
399```
400
401### investigating performance issues
402
403```sql
404-- find most active artists (could cause rate limiting)
405SELECT
406 a.handle,
407 COUNT(t.id) as track_count,
408 MAX(t.created_at) as last_upload,
409 a.pds_url
410FROM artists a
411JOIN tracks t ON a.did = t.artist_did
412GROUP BY a.handle, a.pds_url
413ORDER BY track_count DESC;
414
415-- check upload patterns for rate limit issues
416SELECT
417 DATE_TRUNC('hour', created_at) as hour,
418 COUNT(*) as uploads
419FROM tracks
420WHERE created_at > NOW() - INTERVAL '24 hours'
421GROUP BY hour
422ORDER BY hour DESC;
423```
424
425### schema evolution tracking
426
427```sql
428-- check current migration version
429SELECT version_num FROM alembic_version;
430
431-- inspect recent schema changes via table sizes
432SELECT
433 table_name,
434 pg_size_pretty(pg_total_relation_size(quote_ident(table_name)::regclass)) as total_size
435FROM information_schema.tables
436WHERE table_schema = 'public'
437 AND table_type = 'BASE TABLE'
438ORDER BY pg_total_relation_size(quote_ident(table_name)::regclass) DESC;
439```
440
441## tips and best practices
442
443### jsonb field access
444
445use `->` for json objects and `->>` for text extraction:
446
447```sql
448-- returns json object
449SELECT extra->'metadata' FROM tracks;
450
451-- returns text value
452SELECT extra->>'album' FROM tracks;
453
454-- filter by nested json
455SELECT * FROM tracks WHERE (extra->>'album')::text = 'covid ableton sessions';
456```
457
458### counting with filters
459
460postgres supports conditional aggregates:
461
462```sql
463SELECT
464 COUNT(*) as total,
465 COUNT(*) FILTER (WHERE play_count > 0) as played,
466 COUNT(*) FILTER (WHERE play_count = 0) as unplayed
467FROM tracks;
468```
469
470### working with timestamps
471
472```sql
473-- last 24 hours
474WHERE created_at > NOW() - INTERVAL '24 hours'
475
476-- specific date range
477WHERE created_at BETWEEN '2025-11-01' AND '2025-11-12'
478
479-- group by day/week/month
480DATE_TRUNC('day', created_at)
481DATE_TRUNC('week', created_at)
482DATE_TRUNC('month', created_at)
483```
484
485### project id shortcuts
486
487store frequently used project IDs in your notes:
488
489```
490dev: muddy-flower-98795112
491staging: frosty-math-37367092
492prod: cold-butterfly-11920742
493```
494
495## limitations
496
4971. **read-only focus**: this guide covers read operations only. for migrations, use alembic or neon's schema migration tools.
498
4992. **no write operations**: the neon mcp supports writes, but they're not covered here to prevent accidental data modifications during debugging.
500
5013. **connection pooling**: queries go through connection poolers (`-pooler` in endpoint). for admin operations or schema changes, use direct endpoints.
502
5034. **query timeouts**: complex queries may timeout. break them into smaller operations or add indexes if slow.
504
5055. **default database**: most operations assume `neondb` database. specify `databaseName` if using different database.
506
507## related tools
508
509- **pdsx**: for inspecting ATProto records on PDS (see docs/tools/pdsx.md)
510- **psql**: for interactive postgres sessions using connection strings
511- **alembic**: for database migrations (see alembic/versions/)
512- **neon console**: web UI at https://console.neon.tech
513
514## references
515
516- neon mcp server: https://github.com/neondatabase/mcp-server-neon
517- plyr.fm database models: src/backend/models/
518- ATProto integration: src/backend/_internal/atproto/records.py
519- migration scripts: scripts/backfill_atproto_records.py