Skip to content

Commit 3890a52

Browse files
rianvdmclaude
andcommitted
feat: lock hosted instance to owner; pivot repo to self-host
Add ALLOWED_DISCOGS_USER_ID var (empty = open, default for dev and self-hosters) with rejection gates in both OAuth callback paths and as a belt-and-braces check on every authenticated MCP request, so any pre-existing grants/sessions get invalidated cleanly. Reframe README around a full self-hosting walkthrough and rewrite the marketing page to stop advertising discogs-mcp.com as a shared service, because Discogs API rate limits are too strict to share across users. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d807910 commit 3890a52

8 files changed

Lines changed: 257 additions & 145 deletions

File tree

README.md

Lines changed: 93 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -18,59 +18,110 @@ A powerful **Model Context Protocol (MCP) server** that enables AI assistants to
1818
-**Edge Computing**: Global low-latency responses via Cloudflare Workers
1919
- 🗂️ **Smart Caching**: Intelligent KV-based caching for optimal performance
2020

21-
## 🚀 Quick Start
21+
## ⚠️ This Is Not a Shared Service
2222

23-
### Claude Desktop
23+
**`discogs-mcp.com` is the maintainer's private instance.** It's locked to a single Discogs account and will return a 403 for anyone else.
2424

25-
1. Open Claude Desktop → **Settings****Integrations**
26-
2. Click **Add Integration**
27-
3. Enter the URL:
28-
```
29-
https://discogs-mcp.com/mcp
30-
```
31-
4. Click **Add** - authenticate with Discogs when prompted
25+
Why? The Discogs API rate limit (60 requests per minute, per registered app) is too tight to share across users. One active collection query from a single user can saturate it. Rather than run a broken multi-tenant service, **each user deploys their own Worker with their own Discogs API credentials**.
3226

33-
### Claude Code
27+
The good news: deploying your own copy is straightforward, runs on the Cloudflare Workers free tier, and takes about 10 minutes. See [Self-Hosting](#-self-hosting) below.
28+
29+
## 🚀 Self-Hosting
30+
31+
### Prerequisites
32+
33+
- Node.js 18+
34+
- Cloudflare account (free tier is fine)
35+
- Discogs account with a [registered developer app](https://www.discogs.com/settings/developers) (you'll need a **Consumer Key** and **Consumer Secret**)
36+
37+
### 1. Clone and install
3438

3539
```bash
36-
claude mcp add --transport http discogs https://discogs-mcp.com/mcp
40+
git clone https://github.com/rianvdm/discogs-mcp.git
41+
cd discogs-mcp
42+
npm install
3743
```
3844

39-
### Windsurf
45+
### 2. Create KV namespaces
4046

41-
Add to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`):
47+
```bash
48+
wrangler kv namespace create MCP_SESSIONS --env production
49+
wrangler kv namespace create MCP_LOGS --env production
50+
wrangler kv namespace create OAUTH_KV --env production
51+
```
4252

43-
```json
44-
{
45-
"mcpServers": {
46-
"discogs": {
47-
"serverUrl": "https://discogs-mcp.com/mcp"
48-
}
49-
}
50-
}
53+
Copy the returned IDs into `wrangler.toml` under `[env.production]`.
54+
55+
### 3. Set your Discogs credentials
56+
57+
```bash
58+
wrangler secret put DISCOGS_CONSUMER_KEY --env production
59+
wrangler secret put DISCOGS_CONSUMER_SECRET --env production
60+
wrangler secret put JWT_SECRET --env production # any random string
5161
```
5262

53-
### MCP Inspector (Testing)
63+
### 4. (Optional but recommended) Lock your instance to your own Discogs user
64+
65+
By default, anyone with a Discogs account who discovers your Worker URL can authenticate and consume your rate-limit budget. To restrict it to just you, set `ALLOWED_DISCOGS_USER_ID` in `wrangler.toml` under `[env.production.vars]` to your numeric Discogs user ID:
66+
67+
```toml
68+
[env.production.vars]
69+
ALLOWED_DISCOGS_USER_ID = "123456"
70+
```
71+
72+
You can find your numeric ID by visiting `https://api.discogs.com/users/<your-username>` and looking at the `id` field. Leave the value empty to run an open instance.
73+
74+
### 5. Deploy
75+
76+
```bash
77+
npm run deploy:prod
78+
```
79+
80+
Your Worker URL will be something like `https://discogs-mcp.<your-subdomain>.workers.dev`. The MCP endpoint is `/mcp`.
81+
82+
### 6. Connect your MCP client
83+
84+
Replace `https://your-worker.workers.dev` below with your own URL.
85+
86+
**Claude Desktop** — Settings → Integrations → Add Integration → `https://your-worker.workers.dev/mcp`
87+
88+
**Claude Code**:
5489

5590
```bash
56-
npx @modelcontextprotocol/inspector https://discogs-mcp.com/mcp
91+
claude mcp add --transport http discogs https://your-worker.workers.dev/mcp
5792
```
5893

59-
### Other MCP Clients
94+
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
95+
96+
```json
97+
{
98+
"mcpServers": {
99+
"discogs": {
100+
"serverUrl": "https://your-worker.workers.dev/mcp"
101+
}
102+
}
103+
}
104+
```
60105

61106
**Continue.dev / Zed / Generic:**
62107

63108
```json
64109
{
65-
"mcpServers": {
66-
"discogs": {
67-
"command": "npx",
68-
"args": ["-y", "mcp-remote", "https://discogs-mcp.com/mcp"]
69-
}
70-
}
110+
"mcpServers": {
111+
"discogs": {
112+
"command": "npx",
113+
"args": ["-y", "mcp-remote", "https://your-worker.workers.dev/mcp"]
114+
}
115+
}
71116
}
72117
```
73118

119+
**MCP Inspector (testing)**:
120+
121+
```bash
122+
npx @modelcontextprotocol/inspector https://your-worker.workers.dev/mcp
123+
```
124+
74125
## 🔐 Authentication
75126

76127
This server uses **MCP OAuth 2.1** with Discogs as the identity provider. When you connect for the first time:
@@ -110,65 +161,22 @@ discogs://release/{id} # Specific release details
110161
discogs://search?q={query} # Search results
111162
```
112163

113-
## 🏗️ Development
114-
115-
### Prerequisites
116-
117-
- Node.js 18+
118-
- Cloudflare account
119-
- Discogs Developer Account (for API keys)
120-
121-
### Local Setup
122-
123-
1. **Clone and install**:
124-
125-
```bash
126-
git clone https://github.com/rianvdm/discogs-mcp.git
127-
cd discogs-mcp
128-
npm install
129-
```
130-
131-
2. **Configure environment**:
132-
133-
```bash
134-
# Set your Discogs API credentials as Wrangler secrets
135-
wrangler secret put DISCOGS_CONSUMER_KEY
136-
wrangler secret put DISCOGS_CONSUMER_SECRET
137-
```
138-
139-
3. **Start development server**:
164+
## 🏗️ Local Development
140165

141-
```bash
142-
npm run dev
143-
```
144-
145-
4. **Test with MCP Inspector**:
146-
```bash
147-
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
148-
```
149-
150-
## 🚀 Deployment
151-
152-
1. **Create KV namespaces** and add their IDs to `wrangler.toml` under `[env.production]`:
153-
154-
```bash
155-
wrangler kv namespace create MCP_SESSIONS --env production
156-
wrangler kv namespace create MCP_LOGS --env production
157-
wrangler kv namespace create MCP_RL --env production
158-
wrangler kv namespace create OAUTH_KV --env production
159-
```
166+
```bash
167+
# Set dev secrets (same Discogs app is fine for dev)
168+
wrangler secret put DISCOGS_CONSUMER_KEY
169+
wrangler secret put DISCOGS_CONSUMER_SECRET
170+
wrangler secret put JWT_SECRET
160171

161-
2. **Set production secrets**:
172+
# Run the Worker locally
173+
npm run dev
162174

163-
```bash
164-
wrangler secret put DISCOGS_CONSUMER_KEY --env production
165-
wrangler secret put DISCOGS_CONSUMER_SECRET --env production
166-
```
175+
# Test with MCP Inspector
176+
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
177+
```
167178

168-
3. **Deploy**:
169-
```bash
170-
npm run deploy:prod
171-
```
179+
The default `[vars]` block in `wrangler.toml` leaves `ALLOWED_DISCOGS_USER_ID` empty, so local dev is open to any Discogs account — convenient for testing.
172180

173181
## 🧪 Testing
174182

src/auth/oauth-handler.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@ export interface DiscogsUserProps {
2121
accessTokenSecret: string
2222
}
2323

24+
/**
25+
* If ALLOWED_DISCOGS_USER_ID is set (non-empty), verify that the authenticated
26+
* Discogs identity matches. Returns a 403 response for unauthorized users, or
27+
* null to proceed. Empty/unset = open instance (default for self-hosters).
28+
*/
29+
export function checkAllowlist(
30+
identity: { id: number; username: string },
31+
allowedIdRaw: string | undefined,
32+
): Response | null {
33+
const allowedId = allowedIdRaw?.trim()
34+
if (!allowedId) return null
35+
if (String(identity.id) === allowedId) return null
36+
37+
console.warn(
38+
`[AUTH] Rejected unauthorized user: ${identity.username} (${identity.id})`,
39+
)
40+
return new Response(
41+
`<!DOCTYPE html><html><head><title>Access Restricted</title><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:36rem;margin:4rem auto;padding:0 1.5rem;line-height:1.6;color:#222}h1{color:#b00020}a{color:#0366d6}</style></head><body>
42+
<h1>Access Restricted</h1>
43+
<p>This Discogs MCP instance is private and locked to a single Discogs user. Discogs API rate limits are too strict to share across users, so each person needs to run their own deployment.</p>
44+
<p>Good news: it's open source and easy to self-host on Cloudflare Workers (free tier works fine).</p>
45+
<p><strong><a href="https://github.com/rianvdm/discogs-mcp#self-hosting">Self-hosting instructions →</a></strong></p>
46+
</body></html>`,
47+
{ status: 403, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
48+
)
49+
}
50+
2451
/**
2552
* DefaultHandler for @cloudflare/workers-oauth-provider.
2653
* Only handles auth-related routes. Static routes (/, /health, etc.) are
@@ -139,6 +166,11 @@ async function handleDiscogsCallback(request: Request, env: OAuthEnv): Promise<R
139166
}
140167

141168
const identity = await identityRes.json() as { id: number; username: string }
169+
170+
// Allowlist gate (set on maintainer's deployment; empty = open)
171+
const denied = checkAllowlist(identity, env.ALLOWED_DISCOGS_USER_ID)
172+
if (denied) return denied
173+
142174
const userProps: DiscogsUserProps = {
143175
numericId: String(identity.id),
144176
username: identity.username,
@@ -293,6 +325,10 @@ async function handleManualCallback(request: Request, env: OAuthEnv): Promise<Re
293325

294326
const identity = await identityRes.json() as { id: number; username: string }
295327

328+
// Allowlist gate (set on maintainer's deployment; empty = open)
329+
const denied = checkAllowlist(identity, env.ALLOWED_DISCOGS_USER_ID)
330+
if (denied) return denied
331+
296332
// Store session in KV (7 days)
297333
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000
298334
await env.MCP_SESSIONS.put(

src/index-oauth.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ const SERVER_VERSION = '1.0.0'
1717
// PKCE + standard MCP session TTL (7 days)
1818
const ACCESS_TOKEN_TTL = 7 * 24 * 60 * 60
1919

20+
/**
21+
* Enforce ALLOWED_DISCOGS_USER_ID on authenticated MCP requests.
22+
* Primary gate is at the OAuth callback, but we also check here so that any
23+
* pre-existing grants/sessions issued before the allowlist was set are
24+
* invalidated on their next request.
25+
*/
26+
function isAllowedUser(numericId: string | undefined, env: Env): boolean {
27+
const allowed = env.ALLOWED_DISCOGS_USER_ID?.trim()
28+
if (!allowed) return true
29+
return numericId === allowed
30+
}
31+
32+
function accessDeniedResponse(): Response {
33+
return new Response(
34+
JSON.stringify({
35+
error: 'access_denied',
36+
error_description:
37+
'This Discogs MCP instance is private. Self-host your own: https://github.com/rianvdm/discogs-mcp#self-hosting',
38+
}),
39+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
40+
)
41+
}
42+
2043
/**
2144
* OAuth provider instance — handles all OAuth 2.1 endpoints automatically:
2245
* - /.well-known/oauth-authorization-server (discovery)
@@ -36,6 +59,9 @@ const oauthProvider = new OAuthProvider({
3659
const { server, setContext } = createMcpServer(env, baseUrl)
3760

3861
const props = (ctx as unknown as { props?: DiscogsUserProps }).props
62+
if (props && !isAllowedUser(props.numericId, env)) {
63+
return accessDeniedResponse()
64+
}
3965
if (props?.username && props?.accessToken) {
4066
setContext({
4167
session: {
@@ -84,6 +110,12 @@ async function handleSessionBasedMcp(
84110

85111
const sessionData = JSON.parse(sessionDataStr)
86112

113+
if (!isAllowedUser(sessionData.numericId, env)) {
114+
// Delete the stale unauthorized session so retry fails cleanly with 401
115+
await env.MCP_SESSIONS.delete(`session:${sessionId}`)
116+
return accessDeniedResponse()
117+
}
118+
87119
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
88120
return new Response(JSON.stringify({ error: 'session_expired' }), {
89121
status: 401,

0 commit comments

Comments
 (0)