soundcloud-mcp 0.1.0__tar.gz
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.
- soundcloud_mcp-0.1.0/LICENSE +21 -0
- soundcloud_mcp-0.1.0/PKG-INFO +184 -0
- soundcloud_mcp-0.1.0/README.md +161 -0
- soundcloud_mcp-0.1.0/pyproject.toml +46 -0
- soundcloud_mcp-0.1.0/setup.cfg +4 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/__init__.py +3 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/__main__.py +6 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/auth_cli.py +40 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/client.py +196 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/config.py +18 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/oauth.py +238 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp/server.py +161 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/PKG-INFO +184 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/SOURCES.txt +16 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/dependency_links.txt +1 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/entry_points.txt +3 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/requires.txt +9 -0
- soundcloud_mcp-0.1.0/src/soundcloud_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Shibley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: soundcloud-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for uploading and managing tracks on SoundCloud via the official API
|
|
5
|
+
Author: David Shibley
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/David-J-Shibley/soundcloud-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/David-J-Shibley/soundcloud-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/David-J-Shibley/soundcloud-mcp/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,soundcloud,music-upload,oauth
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: mcp>=1.0.0
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0.0
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# SoundCloud MCP
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/soundcloud-mcp/)
|
|
27
|
+
[](https://opensource.org/licenses/MIT)
|
|
28
|
+
[](https://www.python.org/downloads/)
|
|
29
|
+
[](https://modelcontextprotocol.io)
|
|
30
|
+
|
|
31
|
+
MCP server for uploading and managing tracks on **SoundCloud** using the [official API](https://developers.soundcloud.com/docs/api/guide.html).
|
|
32
|
+
|
|
33
|
+
Use it from Cursor or Claude Desktop to upload MP3s, update metadata, and manage your catalog — pairs naturally with [suno-mcp](https://github.com/David-J-Shibley/suno-mcp) for generate → upload workflows.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Official SoundCloud API** — OAuth 2.1 + PKCE, no browser scraping for uploads
|
|
38
|
+
- **Upload tracks** — MP3, WAV, FLAC with title, description, tags, artwork
|
|
39
|
+
- **Manage library** — list, get, update, delete tracks
|
|
40
|
+
- **Token refresh** — automatic OAuth token renewal
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.10+
|
|
45
|
+
- SoundCloud **Artist Pro** account
|
|
46
|
+
- A registered SoundCloud API app ([soundcloud.com/you/apps](https://soundcloud.com/you/apps))
|
|
47
|
+
|
|
48
|
+
## Register your SoundCloud app
|
|
49
|
+
|
|
50
|
+
| Field | What to enter |
|
|
51
|
+
|-------|---------------|
|
|
52
|
+
| **App name** | `SoundCloud MCP` (or anything descriptive) |
|
|
53
|
+
| **Description** | `Personal MCP server for uploading AI-generated music to my SoundCloud account` |
|
|
54
|
+
| **Website** | `https://github.com/David-J-Shibley/soundcloud-mcp` *(optional)* |
|
|
55
|
+
| **Redirect URI** | `http://127.0.0.1:8765/callback` *(must match exactly)* |
|
|
56
|
+
|
|
57
|
+
The **Website** field is optional — it is **not** used for OAuth. The **Redirect URI** must match character-for-character.
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Install from PyPI
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install soundcloud-mcp
|
|
65
|
+
cp .env.example .env
|
|
66
|
+
# Edit .env with client_id and client_secret from soundcloud.com/you/apps
|
|
67
|
+
soundcloud-mcp-auth
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Install from source
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/David-J-Shibley/soundcloud-mcp.git
|
|
74
|
+
cd soundcloud-mcp
|
|
75
|
+
python3 -m venv .venv
|
|
76
|
+
source .venv/bin/activate
|
|
77
|
+
pip install -e .
|
|
78
|
+
cp .env.example .env
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Authenticate (one time)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
soundcloud-mcp-auth
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Tokens are saved to `~/.soundcloud-mcp/tokens.json`.
|
|
88
|
+
|
|
89
|
+
If the browser page is blank/white, your redirect URI doesn't match — see [Troubleshooting](#troubleshooting).
|
|
90
|
+
|
|
91
|
+
## Cursor
|
|
92
|
+
|
|
93
|
+
Add to `~/.cursor/mcp.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"soundcloud": {
|
|
99
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
100
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
101
|
+
"cwd": "/absolute/path/to/soundcloud-mcp",
|
|
102
|
+
"env": {
|
|
103
|
+
"DYLD_LIBRARY_PATH": "/opt/homebrew/opt/expat/lib"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Put credentials in `.env` in the project directory (recommended) — `cwd` lets the server load them automatically.
|
|
111
|
+
|
|
112
|
+
## Claude Desktop
|
|
113
|
+
|
|
114
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"soundcloud": {
|
|
120
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
121
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
122
|
+
"cwd": "/absolute/path/to/soundcloud-mcp"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Tools
|
|
129
|
+
|
|
130
|
+
| Tool | Description |
|
|
131
|
+
|------|-------------|
|
|
132
|
+
| `soundcloud_get_me` | Your SoundCloud profile |
|
|
133
|
+
| `soundcloud_list_my_tracks` | List your uploaded tracks |
|
|
134
|
+
| `soundcloud_get_track` | Track details by ID |
|
|
135
|
+
| `soundcloud_upload_track` | Upload MP3/WAV/FLAC with metadata |
|
|
136
|
+
| `soundcloud_update_track` | Edit title, description, tags, artwork |
|
|
137
|
+
| `soundcloud_delete_track` | Remove a track |
|
|
138
|
+
|
|
139
|
+
## Example workflow (Suno → SoundCloud)
|
|
140
|
+
|
|
141
|
+
1. Generate a song with **suno-mcp**
|
|
142
|
+
2. Download the MP3 with `suno_download_song`
|
|
143
|
+
3. Upload with **soundcloud-mcp**:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Upload ~/Downloads/suno/my-song.mp3 to SoundCloud as "My New Track" with tags "electronic ai-generated"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
| Variable | Default | Description |
|
|
152
|
+
|----------|---------|-------------|
|
|
153
|
+
| `SOUNDCLOUD_CLIENT_ID` | — | From soundcloud.com/you/apps |
|
|
154
|
+
| `SOUNDCLOUD_CLIENT_SECRET` | — | From soundcloud.com/you/apps |
|
|
155
|
+
| `SOUNDCLOUD_REDIRECT_URI` | `http://127.0.0.1:8765/callback` | Must match app settings |
|
|
156
|
+
| `SOUNDCLOUD_TOKEN_FILE` | `~/.soundcloud-mcp/tokens.json` | OAuth token storage |
|
|
157
|
+
|
|
158
|
+
## Troubleshooting
|
|
159
|
+
|
|
160
|
+
**Blank/white OAuth page** — Redirect URI mismatch. In [soundcloud.com/you/apps](https://soundcloud.com/you/apps), set exactly:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
http://127.0.0.1:8765/callback
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `127.0.0.1` not `localhost`, `http://` not `https://`, no trailing slash.
|
|
167
|
+
|
|
168
|
+
**Not authenticated** — Run `soundcloud-mcp-auth` again.
|
|
169
|
+
|
|
170
|
+
**Manual login** — `soundcloud-mcp-auth --no-browser` prints the URL to paste into your browser.
|
|
171
|
+
|
|
172
|
+
## Disclaimer
|
|
173
|
+
|
|
174
|
+
Unofficial project, not affiliated with SoundCloud. Use in accordance with [SoundCloud's API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use).
|
|
175
|
+
|
|
176
|
+
## Contributing
|
|
177
|
+
|
|
178
|
+
Issues and pull requests welcome on [GitHub](https://github.com/David-J-Shibley/soundcloud-mcp).
|
|
179
|
+
|
|
180
|
+
See [PUBLISHING.md](PUBLISHING.md) for PyPI release instructions.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SoundCloud MCP
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/soundcloud-mcp/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://modelcontextprotocol.io)
|
|
7
|
+
|
|
8
|
+
MCP server for uploading and managing tracks on **SoundCloud** using the [official API](https://developers.soundcloud.com/docs/api/guide.html).
|
|
9
|
+
|
|
10
|
+
Use it from Cursor or Claude Desktop to upload MP3s, update metadata, and manage your catalog — pairs naturally with [suno-mcp](https://github.com/David-J-Shibley/suno-mcp) for generate → upload workflows.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Official SoundCloud API** — OAuth 2.1 + PKCE, no browser scraping for uploads
|
|
15
|
+
- **Upload tracks** — MP3, WAV, FLAC with title, description, tags, artwork
|
|
16
|
+
- **Manage library** — list, get, update, delete tracks
|
|
17
|
+
- **Token refresh** — automatic OAuth token renewal
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- SoundCloud **Artist Pro** account
|
|
23
|
+
- A registered SoundCloud API app ([soundcloud.com/you/apps](https://soundcloud.com/you/apps))
|
|
24
|
+
|
|
25
|
+
## Register your SoundCloud app
|
|
26
|
+
|
|
27
|
+
| Field | What to enter |
|
|
28
|
+
|-------|---------------|
|
|
29
|
+
| **App name** | `SoundCloud MCP` (or anything descriptive) |
|
|
30
|
+
| **Description** | `Personal MCP server for uploading AI-generated music to my SoundCloud account` |
|
|
31
|
+
| **Website** | `https://github.com/David-J-Shibley/soundcloud-mcp` *(optional)* |
|
|
32
|
+
| **Redirect URI** | `http://127.0.0.1:8765/callback` *(must match exactly)* |
|
|
33
|
+
|
|
34
|
+
The **Website** field is optional — it is **not** used for OAuth. The **Redirect URI** must match character-for-character.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Install from PyPI
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install soundcloud-mcp
|
|
42
|
+
cp .env.example .env
|
|
43
|
+
# Edit .env with client_id and client_secret from soundcloud.com/you/apps
|
|
44
|
+
soundcloud-mcp-auth
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Install from source
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/David-J-Shibley/soundcloud-mcp.git
|
|
51
|
+
cd soundcloud-mcp
|
|
52
|
+
python3 -m venv .venv
|
|
53
|
+
source .venv/bin/activate
|
|
54
|
+
pip install -e .
|
|
55
|
+
cp .env.example .env
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Authenticate (one time)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
soundcloud-mcp-auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Tokens are saved to `~/.soundcloud-mcp/tokens.json`.
|
|
65
|
+
|
|
66
|
+
If the browser page is blank/white, your redirect URI doesn't match — see [Troubleshooting](#troubleshooting).
|
|
67
|
+
|
|
68
|
+
## Cursor
|
|
69
|
+
|
|
70
|
+
Add to `~/.cursor/mcp.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"soundcloud": {
|
|
76
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
77
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
78
|
+
"cwd": "/absolute/path/to/soundcloud-mcp",
|
|
79
|
+
"env": {
|
|
80
|
+
"DYLD_LIBRARY_PATH": "/opt/homebrew/opt/expat/lib"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Put credentials in `.env` in the project directory (recommended) — `cwd` lets the server load them automatically.
|
|
88
|
+
|
|
89
|
+
## Claude Desktop
|
|
90
|
+
|
|
91
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"soundcloud": {
|
|
97
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
98
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
99
|
+
"cwd": "/absolute/path/to/soundcloud-mcp"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Tools
|
|
106
|
+
|
|
107
|
+
| Tool | Description |
|
|
108
|
+
|------|-------------|
|
|
109
|
+
| `soundcloud_get_me` | Your SoundCloud profile |
|
|
110
|
+
| `soundcloud_list_my_tracks` | List your uploaded tracks |
|
|
111
|
+
| `soundcloud_get_track` | Track details by ID |
|
|
112
|
+
| `soundcloud_upload_track` | Upload MP3/WAV/FLAC with metadata |
|
|
113
|
+
| `soundcloud_update_track` | Edit title, description, tags, artwork |
|
|
114
|
+
| `soundcloud_delete_track` | Remove a track |
|
|
115
|
+
|
|
116
|
+
## Example workflow (Suno → SoundCloud)
|
|
117
|
+
|
|
118
|
+
1. Generate a song with **suno-mcp**
|
|
119
|
+
2. Download the MP3 with `suno_download_song`
|
|
120
|
+
3. Upload with **soundcloud-mcp**:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Upload ~/Downloads/suno/my-song.mp3 to SoundCloud as "My New Track" with tags "electronic ai-generated"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
| Variable | Default | Description |
|
|
129
|
+
|----------|---------|-------------|
|
|
130
|
+
| `SOUNDCLOUD_CLIENT_ID` | — | From soundcloud.com/you/apps |
|
|
131
|
+
| `SOUNDCLOUD_CLIENT_SECRET` | — | From soundcloud.com/you/apps |
|
|
132
|
+
| `SOUNDCLOUD_REDIRECT_URI` | `http://127.0.0.1:8765/callback` | Must match app settings |
|
|
133
|
+
| `SOUNDCLOUD_TOKEN_FILE` | `~/.soundcloud-mcp/tokens.json` | OAuth token storage |
|
|
134
|
+
|
|
135
|
+
## Troubleshooting
|
|
136
|
+
|
|
137
|
+
**Blank/white OAuth page** — Redirect URI mismatch. In [soundcloud.com/you/apps](https://soundcloud.com/you/apps), set exactly:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
http://127.0.0.1:8765/callback
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use `127.0.0.1` not `localhost`, `http://` not `https://`, no trailing slash.
|
|
144
|
+
|
|
145
|
+
**Not authenticated** — Run `soundcloud-mcp-auth` again.
|
|
146
|
+
|
|
147
|
+
**Manual login** — `soundcloud-mcp-auth --no-browser` prints the URL to paste into your browser.
|
|
148
|
+
|
|
149
|
+
## Disclaimer
|
|
150
|
+
|
|
151
|
+
Unofficial project, not affiliated with SoundCloud. Use in accordance with [SoundCloud's API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use).
|
|
152
|
+
|
|
153
|
+
## Contributing
|
|
154
|
+
|
|
155
|
+
Issues and pull requests welcome on [GitHub](https://github.com/David-J-Shibley/soundcloud-mcp).
|
|
156
|
+
|
|
157
|
+
See [PUBLISHING.md](PUBLISHING.md) for PyPI release instructions.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "soundcloud-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for uploading and managing tracks on SoundCloud via the official API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "David Shibley"}
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"soundcloud",
|
|
19
|
+
"music-upload",
|
|
20
|
+
"oauth",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mcp>=1.0.0",
|
|
24
|
+
"httpx>=0.27.0",
|
|
25
|
+
"pydantic>=2.0.0",
|
|
26
|
+
"python-dotenv>=1.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=7.0.0",
|
|
32
|
+
"pytest-asyncio>=0.21.0",
|
|
33
|
+
"ruff>=0.1.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/David-J-Shibley/soundcloud-mcp"
|
|
41
|
+
Repository = "https://github.com/David-J-Shibley/soundcloud-mcp"
|
|
42
|
+
Issues = "https://github.com/David-J-Shibley/soundcloud-mcp/issues"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
soundcloud-mcp = "soundcloud_mcp.server:main"
|
|
46
|
+
soundcloud-mcp-auth = "soundcloud_mcp.auth_cli:main"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""CLI to complete SoundCloud OAuth login."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
from .config import REDIRECT_URI
|
|
10
|
+
from .oauth import OAuthError, build_authorize_url, run_local_oauth_login, _pkce_pair
|
|
11
|
+
import secrets
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
load_dotenv()
|
|
16
|
+
parser = argparse.ArgumentParser(description="Authenticate with SoundCloud for soundcloud-mcp")
|
|
17
|
+
parser.add_argument("--no-browser", action="store_true", help="Print URL only, don't open browser")
|
|
18
|
+
parser.add_argument("--print-url", action="store_true", help="Print authorize URL and exit")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
if args.print_url:
|
|
22
|
+
state = secrets.token_urlsafe(16)
|
|
23
|
+
_, challenge = _pkce_pair()
|
|
24
|
+
print(build_authorize_url(state=state, code_challenge=challenge))
|
|
25
|
+
print(f"\nRedirect URI configured: {REDIRECT_URI}")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
tokens = run_local_oauth_login(open_browser=not args.no_browser)
|
|
30
|
+
from .config import TOKEN_FILE
|
|
31
|
+
print("Authenticated successfully.")
|
|
32
|
+
print(f"Tokens saved to: {TOKEN_FILE}")
|
|
33
|
+
print(json.dumps({k: tokens[k] for k in ("token_type", "expires_in", "scope") if k in tokens}, indent=2))
|
|
34
|
+
except OAuthError as exc:
|
|
35
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""SoundCloud API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import API_BASE
|
|
12
|
+
from .oauth import OAuthError, get_valid_access_token
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SoundCloudClient:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._http = httpx.AsyncClient(
|
|
18
|
+
base_url=API_BASE,
|
|
19
|
+
timeout=httpx.Timeout(30.0, read=300.0),
|
|
20
|
+
headers={"accept": "application/json; charset=utf-8"},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
async def close(self) -> None:
|
|
24
|
+
await self._http.aclose()
|
|
25
|
+
|
|
26
|
+
async def _auth_headers(self) -> dict[str, str]:
|
|
27
|
+
token = await get_valid_access_token()
|
|
28
|
+
return {"Authorization": f"OAuth {token}"}
|
|
29
|
+
|
|
30
|
+
async def _request(self, method: str, path: str, **kwargs) -> Any:
|
|
31
|
+
headers = kwargs.pop("headers", {})
|
|
32
|
+
headers.update(await self._auth_headers())
|
|
33
|
+
response = await self._http.request(method, path, headers=headers, **kwargs)
|
|
34
|
+
if not response.is_success:
|
|
35
|
+
raise RuntimeError(f"SoundCloud API {path} {response.status_code}: {response.text[:500]}")
|
|
36
|
+
if response.content:
|
|
37
|
+
return response.json()
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
async def get_me(self) -> dict[str, Any]:
|
|
41
|
+
return await self._request("GET", "/me")
|
|
42
|
+
|
|
43
|
+
async def list_my_tracks(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
44
|
+
data = await self._request("GET", "/me/tracks", params={"limit": limit, "linked_partitioning": "true"})
|
|
45
|
+
if isinstance(data, list):
|
|
46
|
+
return data
|
|
47
|
+
return data.get("collection", data)
|
|
48
|
+
|
|
49
|
+
async def get_track(self, track_id: int) -> dict[str, Any]:
|
|
50
|
+
return await self._request("GET", f"/tracks/{track_id}")
|
|
51
|
+
|
|
52
|
+
async def upload_track(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
file_path: str,
|
|
56
|
+
title: str,
|
|
57
|
+
description: str | None = None,
|
|
58
|
+
genre: str | None = None,
|
|
59
|
+
tag_list: str | None = None,
|
|
60
|
+
sharing: str = "public",
|
|
61
|
+
license: str | None = None,
|
|
62
|
+
artwork_path: str | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
path = Path(file_path).expanduser()
|
|
65
|
+
if not path.exists():
|
|
66
|
+
raise FileNotFoundError(f"Audio file not found: {path}")
|
|
67
|
+
|
|
68
|
+
data: dict[str, str] = {
|
|
69
|
+
"track[title]": title,
|
|
70
|
+
"track[sharing]": sharing,
|
|
71
|
+
}
|
|
72
|
+
if description:
|
|
73
|
+
data["track[description]"] = description
|
|
74
|
+
if genre:
|
|
75
|
+
data["track[genre]"] = genre
|
|
76
|
+
if tag_list:
|
|
77
|
+
data["track[tag_list]"] = tag_list
|
|
78
|
+
if license:
|
|
79
|
+
data["track[license]"] = license
|
|
80
|
+
|
|
81
|
+
files: dict[str, Any] = {
|
|
82
|
+
"track[asset_data]": (path.name, path.read_bytes(), "application/octet-stream"),
|
|
83
|
+
}
|
|
84
|
+
if artwork_path:
|
|
85
|
+
art = Path(artwork_path).expanduser()
|
|
86
|
+
if art.exists():
|
|
87
|
+
files["track[artwork_data]"] = (art.name, art.read_bytes(), "image/jpeg")
|
|
88
|
+
|
|
89
|
+
headers = await self._auth_headers()
|
|
90
|
+
response = await self._http.post("/tracks", data=data, files=files, headers=headers)
|
|
91
|
+
if not response.is_success:
|
|
92
|
+
raise RuntimeError(f"Upload failed ({response.status_code}): {response.text[:500]}")
|
|
93
|
+
return response.json()
|
|
94
|
+
|
|
95
|
+
async def update_track(
|
|
96
|
+
self,
|
|
97
|
+
track_id: int,
|
|
98
|
+
*,
|
|
99
|
+
title: str | None = None,
|
|
100
|
+
description: str | None = None,
|
|
101
|
+
genre: str | None = None,
|
|
102
|
+
tag_list: str | None = None,
|
|
103
|
+
sharing: str | None = None,
|
|
104
|
+
artwork_path: str | None = None,
|
|
105
|
+
release: str | None = None,
|
|
106
|
+
label_name: str | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
track: dict[str, str] = {}
|
|
109
|
+
if title is not None:
|
|
110
|
+
track["title"] = title
|
|
111
|
+
if description is not None:
|
|
112
|
+
track["description"] = description
|
|
113
|
+
if genre is not None:
|
|
114
|
+
track["genre"] = genre
|
|
115
|
+
if tag_list is not None:
|
|
116
|
+
track["tag_list"] = tag_list
|
|
117
|
+
if sharing is not None:
|
|
118
|
+
track["sharing"] = sharing
|
|
119
|
+
if release is not None:
|
|
120
|
+
track["release"] = release
|
|
121
|
+
if label_name is not None:
|
|
122
|
+
track["label_name"] = label_name
|
|
123
|
+
|
|
124
|
+
if artwork_path:
|
|
125
|
+
art = Path(artwork_path).expanduser()
|
|
126
|
+
if not art.exists():
|
|
127
|
+
raise FileNotFoundError(f"Artwork not found: {art}")
|
|
128
|
+
mime = "image/png" if art.suffix.lower() == ".png" else "image/jpeg"
|
|
129
|
+
headers = await self._auth_headers()
|
|
130
|
+
data = {f"track[{k}]": v for k, v in track.items()}
|
|
131
|
+
files = {"track[artwork_data]": (art.name, art.read_bytes(), mime)}
|
|
132
|
+
response = await self._http.put(f"/tracks/{track_id}", data=data, files=files, headers=headers)
|
|
133
|
+
if not response.is_success:
|
|
134
|
+
raise RuntimeError(f"Update failed ({response.status_code}): {response.text[:500]}")
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
if not track:
|
|
138
|
+
raise ValueError("No fields to update")
|
|
139
|
+
return await self._request("PUT", f"/tracks/{track_id}", json={"track": track})
|
|
140
|
+
|
|
141
|
+
async def create_playlist(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
title: str,
|
|
145
|
+
track_ids: list[int],
|
|
146
|
+
description: str | None = None,
|
|
147
|
+
sharing: str = "public",
|
|
148
|
+
artwork_path: str | None = None,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
if not track_ids:
|
|
151
|
+
raise ValueError("track_ids required")
|
|
152
|
+
|
|
153
|
+
payload: dict[str, Any] = {
|
|
154
|
+
"playlist": {
|
|
155
|
+
"title": title,
|
|
156
|
+
"sharing": sharing,
|
|
157
|
+
"tracks": [{"id": str(tid)} for tid in track_ids],
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if description:
|
|
161
|
+
payload["playlist"]["description"] = description
|
|
162
|
+
|
|
163
|
+
playlist = await self._request("POST", "/playlists", json=payload)
|
|
164
|
+
|
|
165
|
+
if artwork_path:
|
|
166
|
+
art = Path(artwork_path).expanduser()
|
|
167
|
+
if not art.exists():
|
|
168
|
+
raise FileNotFoundError(f"Artwork not found: {art}")
|
|
169
|
+
mime = "image/png" if art.suffix.lower() == ".png" else "image/jpeg"
|
|
170
|
+
playlist_id = playlist["id"]
|
|
171
|
+
headers = await self._auth_headers()
|
|
172
|
+
data = {f"playlist[{k}]": v for k, v in {"title": title, "sharing": sharing}.items()}
|
|
173
|
+
if description:
|
|
174
|
+
data["playlist[description]"] = description
|
|
175
|
+
files = {"playlist[artwork_data]": (art.name, art.read_bytes(), mime)}
|
|
176
|
+
response = await self._http.put(f"/playlists/{playlist_id}", data=data, files=files, headers=headers)
|
|
177
|
+
if not response.is_success:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"Playlist artwork update failed ({response.status_code}): {response.text[:500]}"
|
|
180
|
+
)
|
|
181
|
+
playlist = response.json()
|
|
182
|
+
|
|
183
|
+
return playlist
|
|
184
|
+
|
|
185
|
+
async def delete_track(self, track_id: int) -> None:
|
|
186
|
+
await self._request("DELETE", f"/tracks/{track_id}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_client: SoundCloudClient | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_client() -> SoundCloudClient:
|
|
193
|
+
global _client
|
|
194
|
+
if _client is None:
|
|
195
|
+
_client = SoundCloudClient()
|
|
196
|
+
return _client
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration for SoundCloud MCP."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
API_BASE = "https://api.soundcloud.com"
|
|
11
|
+
AUTH_BASE = "https://secure.soundcloud.com"
|
|
12
|
+
|
|
13
|
+
CLIENT_ID = os.getenv("SOUNDCLOUD_CLIENT_ID", "").strip()
|
|
14
|
+
CLIENT_SECRET = os.getenv("SOUNDCLOUD_CLIENT_SECRET", "").strip()
|
|
15
|
+
REDIRECT_URI = os.getenv("SOUNDCLOUD_REDIRECT_URI", "http://127.0.0.1:8765/callback").strip()
|
|
16
|
+
|
|
17
|
+
_default_token = Path.home() / ".soundcloud-mcp" / "tokens.json"
|
|
18
|
+
TOKEN_FILE = Path(os.getenv("SOUNDCLOUD_TOKEN_FILE", str(_default_token))).expanduser()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""OAuth 2.1 + PKCE helpers and token storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import webbrowser
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .config import AUTH_BASE, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, TOKEN_FILE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OAuthError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
26
|
+
"""Match SoundCloud's official sc-api-auth.mjs PKCE generation."""
|
|
27
|
+
verifier_bytes = secrets.token_bytes(32)
|
|
28
|
+
verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode()
|
|
29
|
+
challenge = base64.urlsafe_b64encode(
|
|
30
|
+
hashlib.sha256(verifier.encode()).digest()
|
|
31
|
+
).rstrip(b"=").decode()
|
|
32
|
+
return verifier, challenge
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_tokens() -> dict[str, Any] | None:
|
|
36
|
+
if not TOKEN_FILE.exists():
|
|
37
|
+
return None
|
|
38
|
+
return json.loads(TOKEN_FILE.read_text())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_tokens(data: dict[str, Any]) -> None:
|
|
42
|
+
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
TOKEN_FILE.write_text(json.dumps(data, indent=2))
|
|
44
|
+
TOKEN_FILE.chmod(0o600)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_authorize_url(*, state: str, code_challenge: str) -> str:
|
|
48
|
+
params = {
|
|
49
|
+
"client_id": CLIENT_ID,
|
|
50
|
+
"redirect_uri": REDIRECT_URI,
|
|
51
|
+
"response_type": "code",
|
|
52
|
+
"code_challenge": code_challenge,
|
|
53
|
+
"code_challenge_method": "S256",
|
|
54
|
+
"state": state,
|
|
55
|
+
}
|
|
56
|
+
return f"{AUTH_BASE}/authorize?{urllib.parse.urlencode(params)}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def exchange_code(code: str, code_verifier: str) -> dict[str, Any]:
|
|
60
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
61
|
+
response = await client.post(
|
|
62
|
+
f"{AUTH_BASE}/oauth/token",
|
|
63
|
+
headers={
|
|
64
|
+
"accept": "application/json; charset=utf-8",
|
|
65
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
66
|
+
},
|
|
67
|
+
data={
|
|
68
|
+
"grant_type": "authorization_code",
|
|
69
|
+
"client_id": CLIENT_ID,
|
|
70
|
+
"client_secret": CLIENT_SECRET,
|
|
71
|
+
"redirect_uri": REDIRECT_URI,
|
|
72
|
+
"code_verifier": code_verifier,
|
|
73
|
+
"code": code,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
if not response.is_success:
|
|
77
|
+
raise OAuthError(f"Token exchange failed ({response.status_code}): {response.text[:300]}")
|
|
78
|
+
payload = response.json()
|
|
79
|
+
payload["obtained_at"] = int(time.time())
|
|
80
|
+
_save_tokens(payload)
|
|
81
|
+
return payload
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def refresh_access_token(refresh_token: str) -> dict[str, Any]:
|
|
85
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
86
|
+
response = await client.post(
|
|
87
|
+
f"{AUTH_BASE}/oauth/token",
|
|
88
|
+
headers={
|
|
89
|
+
"accept": "application/json; charset=utf-8",
|
|
90
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
91
|
+
},
|
|
92
|
+
data={
|
|
93
|
+
"grant_type": "refresh_token",
|
|
94
|
+
"client_id": CLIENT_ID,
|
|
95
|
+
"client_secret": CLIENT_SECRET,
|
|
96
|
+
"refresh_token": refresh_token,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
if not response.is_success:
|
|
100
|
+
raise OAuthError(f"Token refresh failed ({response.status_code}): {response.text[:300]}")
|
|
101
|
+
payload = response.json()
|
|
102
|
+
payload["obtained_at"] = int(time.time())
|
|
103
|
+
_save_tokens(payload)
|
|
104
|
+
return payload
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def get_valid_access_token() -> str:
|
|
108
|
+
if not CLIENT_ID or not CLIENT_SECRET:
|
|
109
|
+
raise OAuthError(
|
|
110
|
+
"Missing SOUNDCLOUD_CLIENT_ID or SOUNDCLOUD_CLIENT_SECRET. "
|
|
111
|
+
"Copy .env.example to .env and add credentials from https://soundcloud.com/you/apps"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
tokens = _load_tokens()
|
|
115
|
+
if not tokens:
|
|
116
|
+
raise OAuthError(
|
|
117
|
+
"Not authenticated. Run: soundcloud-mcp-auth"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
access_token = tokens.get("access_token")
|
|
121
|
+
refresh_token = tokens.get("refresh_token")
|
|
122
|
+
obtained_at = tokens.get("obtained_at", 0)
|
|
123
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
124
|
+
|
|
125
|
+
# Refresh 5 minutes before expiry; SoundCloud refresh tokens are single-use
|
|
126
|
+
if access_token and time.time() < obtained_at + expires_in - 300:
|
|
127
|
+
return access_token
|
|
128
|
+
|
|
129
|
+
if not refresh_token:
|
|
130
|
+
raise OAuthError("Access token expired and no refresh token. Run: soundcloud-mcp-auth")
|
|
131
|
+
|
|
132
|
+
refreshed = await refresh_access_token(refresh_token)
|
|
133
|
+
return refreshed["access_token"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_local_oauth_login(*, open_browser: bool = True) -> dict[str, Any]:
|
|
137
|
+
"""Open browser, capture OAuth callback on localhost, exchange code for tokens."""
|
|
138
|
+
import asyncio
|
|
139
|
+
|
|
140
|
+
if not CLIENT_ID or not CLIENT_SECRET:
|
|
141
|
+
raise OAuthError("Set SOUNDCLOUD_CLIENT_ID and SOUNDCLOUD_CLIENT_SECRET in .env first")
|
|
142
|
+
|
|
143
|
+
state = secrets.token_urlsafe(16)
|
|
144
|
+
verifier, challenge = _pkce_pair()
|
|
145
|
+
auth_url = build_authorize_url(state=state, code_challenge=challenge)
|
|
146
|
+
|
|
147
|
+
parsed = urllib.parse.urlparse(REDIRECT_URI)
|
|
148
|
+
host = parsed.hostname or "127.0.0.1"
|
|
149
|
+
port = parsed.port or 8765
|
|
150
|
+
expected_paths = {
|
|
151
|
+
parsed.path or "/callback",
|
|
152
|
+
(parsed.path or "/callback").rstrip("/") or "/",
|
|
153
|
+
((parsed.path or "/callback").rstrip("/") or "/") + "/",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
result: dict[str, str] = {}
|
|
157
|
+
error: dict[str, str] = {}
|
|
158
|
+
done = False
|
|
159
|
+
|
|
160
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
161
|
+
def do_GET(self):
|
|
162
|
+
self._handle()
|
|
163
|
+
|
|
164
|
+
def do_POST(self):
|
|
165
|
+
self._handle()
|
|
166
|
+
|
|
167
|
+
def _handle(self):
|
|
168
|
+
nonlocal done
|
|
169
|
+
query = urllib.parse.urlparse(self.path)
|
|
170
|
+
if query.path not in expected_paths:
|
|
171
|
+
self.send_response(404)
|
|
172
|
+
self.end_headers()
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
params = urllib.parse.parse_qs(query.query)
|
|
176
|
+
if self.command == "POST":
|
|
177
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
178
|
+
body = self.rfile.read(length).decode() if length else ""
|
|
179
|
+
params.update(urllib.parse.parse_qs(body))
|
|
180
|
+
|
|
181
|
+
if params.get("error"):
|
|
182
|
+
error["message"] = params.get("error_description", params["error"])[0]
|
|
183
|
+
body = f"<h1>SoundCloud login failed</h1><p>{error['message']}</p>".encode()
|
|
184
|
+
elif params.get("state", [""])[0] != state:
|
|
185
|
+
error["message"] = "State mismatch"
|
|
186
|
+
body = b"<h1>Invalid state</h1><p>You can close this tab.</p>"
|
|
187
|
+
elif not params.get("code"):
|
|
188
|
+
error["message"] = "Missing authorization code"
|
|
189
|
+
body = b"<h1>Missing code</h1><p>You can close this tab.</p>"
|
|
190
|
+
else:
|
|
191
|
+
result["code"] = params["code"][0]
|
|
192
|
+
body = b"<h1>SoundCloud connected!</h1><p>You can close this tab and return to Cursor.</p>"
|
|
193
|
+
done = True
|
|
194
|
+
|
|
195
|
+
self.send_response(200)
|
|
196
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
197
|
+
self.end_headers()
|
|
198
|
+
self.wfile.write(body)
|
|
199
|
+
|
|
200
|
+
def log_message(self, format, *args):
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
server = HTTPServer((host, port), CallbackHandler)
|
|
204
|
+
|
|
205
|
+
print("SoundCloud OAuth login")
|
|
206
|
+
print(f"Redirect URI (must match app settings exactly): {REDIRECT_URI}")
|
|
207
|
+
print()
|
|
208
|
+
print("If the browser page is blank/white, the redirect URI in your app")
|
|
209
|
+
print("at https://soundcloud.com/you/apps does not match the line above.")
|
|
210
|
+
print("Common fixes:")
|
|
211
|
+
print(" - Use http://127.0.0.1:8765/callback (not localhost)")
|
|
212
|
+
print(" - Include http:// (not https)")
|
|
213
|
+
print(" - No trailing slash")
|
|
214
|
+
print()
|
|
215
|
+
print(f"Authorize URL:\n{auth_url}\n")
|
|
216
|
+
|
|
217
|
+
if open_browser:
|
|
218
|
+
print("Opening browser...")
|
|
219
|
+
webbrowser.open(auth_url)
|
|
220
|
+
else:
|
|
221
|
+
print("Paste the authorize URL into your browser manually.")
|
|
222
|
+
|
|
223
|
+
server.timeout = 1
|
|
224
|
+
deadline = time.time() + 300
|
|
225
|
+
while time.time() < deadline and not done and "code" not in result and not error:
|
|
226
|
+
server.handle_request()
|
|
227
|
+
|
|
228
|
+
server.server_close()
|
|
229
|
+
|
|
230
|
+
if error:
|
|
231
|
+
raise OAuthError(error.get("message", "OAuth login failed"))
|
|
232
|
+
if "code" not in result:
|
|
233
|
+
raise OAuthError(
|
|
234
|
+
"Timed out waiting for OAuth callback. "
|
|
235
|
+
"If the SoundCloud page was blank, fix the redirect URI in your app settings."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return asyncio.run(exchange_code(result["code"], verifier))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""SoundCloud MCP Server."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from .client import get_client
|
|
12
|
+
|
|
13
|
+
load_dotenv()
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP("soundcloud_mcp")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _json(data) -> str:
|
|
19
|
+
return json.dumps(data, indent=2, default=str)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ListTracksInput(BaseModel):
|
|
23
|
+
limit: int = Field(default=50, ge=1, le=200, description="Max tracks to return")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TrackIdInput(BaseModel):
|
|
27
|
+
track_id: int = Field(description="SoundCloud track ID")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UploadTrackInput(BaseModel):
|
|
31
|
+
file_path: str = Field(description="Path to audio file (MP3, WAV, FLAC, etc.)")
|
|
32
|
+
title: str = Field(description="Track title")
|
|
33
|
+
description: str | None = Field(default=None, description="Track description")
|
|
34
|
+
genre: str | None = Field(default=None, description="Primary genre")
|
|
35
|
+
tag_list: str | None = Field(default=None, description="Space-separated tags")
|
|
36
|
+
sharing: str = Field(default="public", description="'public' or 'private'")
|
|
37
|
+
license: str | None = Field(default=None, description="e.g. cc-by, all-rights-reserved")
|
|
38
|
+
artwork_path: str | None = Field(default=None, description="Optional cover art image path")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UpdateTrackInput(BaseModel):
|
|
42
|
+
track_id: int = Field(description="SoundCloud track ID")
|
|
43
|
+
title: str | None = None
|
|
44
|
+
description: str | None = None
|
|
45
|
+
genre: str | None = None
|
|
46
|
+
tag_list: str | None = None
|
|
47
|
+
sharing: str | None = None
|
|
48
|
+
artwork_path: str | None = None
|
|
49
|
+
release: str | None = Field(default=None, description="Album / release name")
|
|
50
|
+
label_name: str | None = Field(default=None, description="Record label name")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CreatePlaylistInput(BaseModel):
|
|
54
|
+
title: str = Field(description="Playlist title")
|
|
55
|
+
track_ids: list[int] = Field(description="SoundCloud track IDs in order")
|
|
56
|
+
description: str | None = None
|
|
57
|
+
sharing: str = Field(default="public", description="'public' or 'private'")
|
|
58
|
+
artwork_path: str | None = Field(default=None, description="Optional cover art image path")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool(name="soundcloud_get_me")
|
|
62
|
+
async def soundcloud_get_me() -> str:
|
|
63
|
+
"""Get your authenticated SoundCloud profile."""
|
|
64
|
+
return _json(await get_client().get_me())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp.tool(name="soundcloud_list_my_tracks")
|
|
68
|
+
async def soundcloud_list_my_tracks(params: ListTracksInput) -> str:
|
|
69
|
+
"""List tracks uploaded to your SoundCloud account."""
|
|
70
|
+
return _json(await get_client().list_my_tracks(limit=params.limit))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool(name="soundcloud_get_track")
|
|
74
|
+
async def soundcloud_get_track(params: TrackIdInput) -> str:
|
|
75
|
+
"""Get details for a specific track by ID."""
|
|
76
|
+
return _json(await get_client().get_track(params.track_id))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mcp.tool(name="soundcloud_upload_track")
|
|
80
|
+
async def soundcloud_upload_track(params: UploadTrackInput) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Upload an audio file to SoundCloud.
|
|
83
|
+
|
|
84
|
+
Requires OAuth login (run soundcloud-mcp-auth once). Uses your Artist Pro API credentials.
|
|
85
|
+
"""
|
|
86
|
+
track = await get_client().upload_track(
|
|
87
|
+
file_path=params.file_path,
|
|
88
|
+
title=params.title,
|
|
89
|
+
description=params.description,
|
|
90
|
+
genre=params.genre,
|
|
91
|
+
tag_list=params.tag_list,
|
|
92
|
+
sharing=params.sharing,
|
|
93
|
+
license=params.license,
|
|
94
|
+
artwork_path=params.artwork_path,
|
|
95
|
+
)
|
|
96
|
+
return _json({
|
|
97
|
+
"id": track.get("id"),
|
|
98
|
+
"title": track.get("title"),
|
|
99
|
+
"permalink_url": track.get("permalink_url"),
|
|
100
|
+
"sharing": track.get("sharing"),
|
|
101
|
+
"state": track.get("state"),
|
|
102
|
+
"track": track,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(name="soundcloud_update_track")
|
|
107
|
+
async def soundcloud_update_track(params: UpdateTrackInput) -> str:
|
|
108
|
+
"""Update metadata for an existing track."""
|
|
109
|
+
return _json(await get_client().update_track(
|
|
110
|
+
params.track_id,
|
|
111
|
+
title=params.title,
|
|
112
|
+
description=params.description,
|
|
113
|
+
genre=params.genre,
|
|
114
|
+
tag_list=params.tag_list,
|
|
115
|
+
sharing=params.sharing,
|
|
116
|
+
artwork_path=params.artwork_path,
|
|
117
|
+
release=params.release,
|
|
118
|
+
label_name=params.label_name,
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool(name="soundcloud_create_playlist")
|
|
123
|
+
async def soundcloud_create_playlist(params: CreatePlaylistInput) -> str:
|
|
124
|
+
"""Create a SoundCloud playlist (album/set) with ordered tracks and optional cover art."""
|
|
125
|
+
playlist = await get_client().create_playlist(
|
|
126
|
+
title=params.title,
|
|
127
|
+
track_ids=params.track_ids,
|
|
128
|
+
description=params.description,
|
|
129
|
+
sharing=params.sharing,
|
|
130
|
+
artwork_path=params.artwork_path,
|
|
131
|
+
)
|
|
132
|
+
return _json({
|
|
133
|
+
"id": playlist.get("id"),
|
|
134
|
+
"title": playlist.get("title"),
|
|
135
|
+
"permalink_url": playlist.get("permalink_url"),
|
|
136
|
+
"track_count": playlist.get("track_count"),
|
|
137
|
+
"playlist": playlist,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@mcp.tool(name="soundcloud_delete_track")
|
|
142
|
+
async def soundcloud_delete_track(params: TrackIdInput) -> str:
|
|
143
|
+
"""Delete a track from your SoundCloud account."""
|
|
144
|
+
await get_client().delete_track(params.track_id)
|
|
145
|
+
return _json({"deleted": True, "track_id": params.track_id})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main() -> None:
|
|
149
|
+
def _cleanup():
|
|
150
|
+
try:
|
|
151
|
+
client = get_client()
|
|
152
|
+
asyncio.get_event_loop().run_until_complete(client.close())
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
atexit.register(_cleanup)
|
|
157
|
+
mcp.run(transport="stdio")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: soundcloud-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for uploading and managing tracks on SoundCloud via the official API
|
|
5
|
+
Author: David Shibley
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/David-J-Shibley/soundcloud-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/David-J-Shibley/soundcloud-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/David-J-Shibley/soundcloud-mcp/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,soundcloud,music-upload,oauth
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: mcp>=1.0.0
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0.0
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# SoundCloud MCP
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/soundcloud-mcp/)
|
|
27
|
+
[](https://opensource.org/licenses/MIT)
|
|
28
|
+
[](https://www.python.org/downloads/)
|
|
29
|
+
[](https://modelcontextprotocol.io)
|
|
30
|
+
|
|
31
|
+
MCP server for uploading and managing tracks on **SoundCloud** using the [official API](https://developers.soundcloud.com/docs/api/guide.html).
|
|
32
|
+
|
|
33
|
+
Use it from Cursor or Claude Desktop to upload MP3s, update metadata, and manage your catalog — pairs naturally with [suno-mcp](https://github.com/David-J-Shibley/suno-mcp) for generate → upload workflows.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Official SoundCloud API** — OAuth 2.1 + PKCE, no browser scraping for uploads
|
|
38
|
+
- **Upload tracks** — MP3, WAV, FLAC with title, description, tags, artwork
|
|
39
|
+
- **Manage library** — list, get, update, delete tracks
|
|
40
|
+
- **Token refresh** — automatic OAuth token renewal
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.10+
|
|
45
|
+
- SoundCloud **Artist Pro** account
|
|
46
|
+
- A registered SoundCloud API app ([soundcloud.com/you/apps](https://soundcloud.com/you/apps))
|
|
47
|
+
|
|
48
|
+
## Register your SoundCloud app
|
|
49
|
+
|
|
50
|
+
| Field | What to enter |
|
|
51
|
+
|-------|---------------|
|
|
52
|
+
| **App name** | `SoundCloud MCP` (or anything descriptive) |
|
|
53
|
+
| **Description** | `Personal MCP server for uploading AI-generated music to my SoundCloud account` |
|
|
54
|
+
| **Website** | `https://github.com/David-J-Shibley/soundcloud-mcp` *(optional)* |
|
|
55
|
+
| **Redirect URI** | `http://127.0.0.1:8765/callback` *(must match exactly)* |
|
|
56
|
+
|
|
57
|
+
The **Website** field is optional — it is **not** used for OAuth. The **Redirect URI** must match character-for-character.
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Install from PyPI
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install soundcloud-mcp
|
|
65
|
+
cp .env.example .env
|
|
66
|
+
# Edit .env with client_id and client_secret from soundcloud.com/you/apps
|
|
67
|
+
soundcloud-mcp-auth
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Install from source
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/David-J-Shibley/soundcloud-mcp.git
|
|
74
|
+
cd soundcloud-mcp
|
|
75
|
+
python3 -m venv .venv
|
|
76
|
+
source .venv/bin/activate
|
|
77
|
+
pip install -e .
|
|
78
|
+
cp .env.example .env
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Authenticate (one time)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
soundcloud-mcp-auth
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Tokens are saved to `~/.soundcloud-mcp/tokens.json`.
|
|
88
|
+
|
|
89
|
+
If the browser page is blank/white, your redirect URI doesn't match — see [Troubleshooting](#troubleshooting).
|
|
90
|
+
|
|
91
|
+
## Cursor
|
|
92
|
+
|
|
93
|
+
Add to `~/.cursor/mcp.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"soundcloud": {
|
|
99
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
100
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
101
|
+
"cwd": "/absolute/path/to/soundcloud-mcp",
|
|
102
|
+
"env": {
|
|
103
|
+
"DYLD_LIBRARY_PATH": "/opt/homebrew/opt/expat/lib"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Put credentials in `.env` in the project directory (recommended) — `cwd` lets the server load them automatically.
|
|
111
|
+
|
|
112
|
+
## Claude Desktop
|
|
113
|
+
|
|
114
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"soundcloud": {
|
|
120
|
+
"command": "/absolute/path/to/soundcloud-mcp/.venv/bin/python",
|
|
121
|
+
"args": ["-m", "soundcloud_mcp"],
|
|
122
|
+
"cwd": "/absolute/path/to/soundcloud-mcp"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Tools
|
|
129
|
+
|
|
130
|
+
| Tool | Description |
|
|
131
|
+
|------|-------------|
|
|
132
|
+
| `soundcloud_get_me` | Your SoundCloud profile |
|
|
133
|
+
| `soundcloud_list_my_tracks` | List your uploaded tracks |
|
|
134
|
+
| `soundcloud_get_track` | Track details by ID |
|
|
135
|
+
| `soundcloud_upload_track` | Upload MP3/WAV/FLAC with metadata |
|
|
136
|
+
| `soundcloud_update_track` | Edit title, description, tags, artwork |
|
|
137
|
+
| `soundcloud_delete_track` | Remove a track |
|
|
138
|
+
|
|
139
|
+
## Example workflow (Suno → SoundCloud)
|
|
140
|
+
|
|
141
|
+
1. Generate a song with **suno-mcp**
|
|
142
|
+
2. Download the MP3 with `suno_download_song`
|
|
143
|
+
3. Upload with **soundcloud-mcp**:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Upload ~/Downloads/suno/my-song.mp3 to SoundCloud as "My New Track" with tags "electronic ai-generated"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
| Variable | Default | Description |
|
|
152
|
+
|----------|---------|-------------|
|
|
153
|
+
| `SOUNDCLOUD_CLIENT_ID` | — | From soundcloud.com/you/apps |
|
|
154
|
+
| `SOUNDCLOUD_CLIENT_SECRET` | — | From soundcloud.com/you/apps |
|
|
155
|
+
| `SOUNDCLOUD_REDIRECT_URI` | `http://127.0.0.1:8765/callback` | Must match app settings |
|
|
156
|
+
| `SOUNDCLOUD_TOKEN_FILE` | `~/.soundcloud-mcp/tokens.json` | OAuth token storage |
|
|
157
|
+
|
|
158
|
+
## Troubleshooting
|
|
159
|
+
|
|
160
|
+
**Blank/white OAuth page** — Redirect URI mismatch. In [soundcloud.com/you/apps](https://soundcloud.com/you/apps), set exactly:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
http://127.0.0.1:8765/callback
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `127.0.0.1` not `localhost`, `http://` not `https://`, no trailing slash.
|
|
167
|
+
|
|
168
|
+
**Not authenticated** — Run `soundcloud-mcp-auth` again.
|
|
169
|
+
|
|
170
|
+
**Manual login** — `soundcloud-mcp-auth --no-browser` prints the URL to paste into your browser.
|
|
171
|
+
|
|
172
|
+
## Disclaimer
|
|
173
|
+
|
|
174
|
+
Unofficial project, not affiliated with SoundCloud. Use in accordance with [SoundCloud's API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use).
|
|
175
|
+
|
|
176
|
+
## Contributing
|
|
177
|
+
|
|
178
|
+
Issues and pull requests welcome on [GitHub](https://github.com/David-J-Shibley/soundcloud-mcp).
|
|
179
|
+
|
|
180
|
+
See [PUBLISHING.md](PUBLISHING.md) for PyPI release instructions.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/soundcloud_mcp/__init__.py
|
|
5
|
+
src/soundcloud_mcp/__main__.py
|
|
6
|
+
src/soundcloud_mcp/auth_cli.py
|
|
7
|
+
src/soundcloud_mcp/client.py
|
|
8
|
+
src/soundcloud_mcp/config.py
|
|
9
|
+
src/soundcloud_mcp/oauth.py
|
|
10
|
+
src/soundcloud_mcp/server.py
|
|
11
|
+
src/soundcloud_mcp.egg-info/PKG-INFO
|
|
12
|
+
src/soundcloud_mcp.egg-info/SOURCES.txt
|
|
13
|
+
src/soundcloud_mcp.egg-info/dependency_links.txt
|
|
14
|
+
src/soundcloud_mcp.egg-info/entry_points.txt
|
|
15
|
+
src/soundcloud_mcp.egg-info/requires.txt
|
|
16
|
+
src/soundcloud_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
soundcloud_mcp
|