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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/soundcloud-mcp.svg)](https://pypi.org/project/soundcloud-mcp/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
29
+ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/soundcloud-mcp.svg)](https://pypi.org/project/soundcloud-mcp/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
+ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """SoundCloud MCP — upload and manage tracks via the official API."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the package as a module: python -m soundcloud_mcp"""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ 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
+ [![PyPI version](https://img.shields.io/pypi/v/soundcloud-mcp.svg)](https://pypi.org/project/soundcloud-mcp/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
29
+ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](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,3 @@
1
+ [console_scripts]
2
+ soundcloud-mcp = soundcloud_mcp.server:main
3
+ soundcloud-mcp-auth = soundcloud_mcp.auth_cli:main
@@ -0,0 +1,9 @@
1
+ mcp>=1.0.0
2
+ httpx>=0.27.0
3
+ pydantic>=2.0.0
4
+ python-dotenv>=1.0.0
5
+
6
+ [dev]
7
+ pytest>=7.0.0
8
+ pytest-asyncio>=0.21.0
9
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ soundcloud_mcp