npm package: @serpstat/oauth-dcr-cleaner
Description: Middleware proxy for Ory Hydra Dynamic Client Registration to fix empty metadata fields validation issues with MCP clients like Claude Code
Author: Serpstat
Latest version: 1.0.0

# OAuth DCR Cleaner Proxy

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org/)

Middleware proxy for Ory Hydra Dynamic Client Registration (DCR) that removes empty metadata fields from responses to fix validation issues with MCP clients like Claude Code.

## The Problem

Claude Code (v2.0.65+) and other mostly Anthropics MCP clients use strict validation for OAuth Dynamic Client Registration responses. When optional metadata fields like `client_uri`, `logo_uri`, or `contacts` are returned as empty strings or empty arrays or null, validation fails:

```javascript
ZodError: [
  {
    "code": "invalid_format",
    "format": "url",
    "path": ["client_uri"],
    "message": "Invalid URL"
  }
]
```

**Root Cause:**
- Claude Code doesn't send metadata fields during DCR registration
- Ory Hydra returns empty strings `""` for unset optional fields
- Claude Code validates these as invalid URLs and fails

Related issue: [anthropics/claude-code#13685](https://github.com/anthropics/claude-code/issues/13685)

## The Solution

This proxy sits between the OAuth client and Ory Hydra, removing empty metadata fields from DCR responses before they reach the client:

```
Claude Code → DCR Proxy (Port 4446) → Ory Hydra (Port 4444)
                    ↓
             Removes empty fields
                    ↓
  Claude Code ← Clean Response ✅
```

## Features

- Removes empty strings, null values, and empty arrays
- Configurable list of fields to clean
- Proxies POST /oauth2/register (registration)
- Proxies GET /oauth2/register/:clientId (get client info)
- Pass-through for other OAuth endpoints
- Detailed logging with Winston
- Health check endpoint
- Docker support
- TypeScript with full type safety

## Installation

### Via npm

```bash
npm install -g @serpstat/oauth-dcr-cleaner
```

### From source

```bash
git clone https://github.com/SerpstatGlobal/oauth-dcr-cleaner.git
cd oauth-dcr-cleaner
npm install
npm run build
```

## Usage

### Production with PM2 (Recommended)

```bash
# Install dependencies and build
npm install
npm run build

# Start with PM2
pm2 start ecosystem.config.js

# View logs
pm2 logs oauth-dcr-cleaner

# Monitor
pm2 monit

# Restart
pm2 restart oauth-dcr-cleaner

# Stop
pm2 stop oauth-dcr-cleaner
```

**Edit `ecosystem.config.js` to configure:**
```javascript
env: {
  NODE_ENV: 'production',
  PROXY_PORT: 4446,
  HYDRA_URL: 'http://127.0.0.1:4444',
  FIELDS_TO_CLEAN: 'client_uri,logo_uri,tos_uri,policy_uri,contacts',
  LOG_LEVEL: 'info',
}
```

### Command Line (Development)

```bash
# Set environment variables
export HYDRA_URL=http://127.0.0.1:4444
export PROXY_PORT=4446

# Start the proxy
npm run dev
```

### Docker (Optional)

<details>
<summary>Click to expand Docker instructions</summary>

**Build and run with Docker Compose:**

```bash
# Clone the repository
git clone https://github.com/SerpstatGlobal/oauth-dcr-cleaner.git
cd oauth-dcr-cleaner

# Edit docker-compose.yml to configure your HYDRA_URL

# Build and start
docker-compose up -d --build

# View logs
docker-compose logs -f

# Stop
docker-compose down
```

**Or build Docker image manually:**

```bash
# Build the image
docker build -t oauth-dcr-cleaner .

# Run the container
docker run -d \
  -p 4446:4446 \
  -e HYDRA_URL=http://host.docker.internal:4444 \
  -e FIELDS_TO_CLEAN=client_uri,logo_uri,tos_uri,policy_uri,contacts \
  -e LOG_LEVEL=info \
  oauth-dcr-cleaner
```

</details>

## Configuration

### Environment Variables

| Variable          | Default                                           | Description                                       |
|-------------------|---------------------------------------------------|---------------------------------------------------|
| `PROXY_PORT`      | `4446`                                            | Port for the proxy server                         |
| `HYDRA_URL`       | `http://127.0.0.1:4444`                           | Ory Hydra base URL                                |
| `FIELDS_TO_CLEAN` | `client_uri,logo_uri,tos_uri,policy_uri,contacts` | Comma-separated list of fields to remove if empty |
| `LOG_LEVEL`       | `info`                                            | Logging level (error, warn, info, debug)          |

### Ory Hydra Configuration

Update your OAuth discovery metadata to point to the proxy:

```yaml
# Before
registration_endpoint: https://auth.example.com/oauth2/register

# After
registration_endpoint: https://auth.example.com:4446/oauth2/register
```

Or if using nginx:

```nginx
location = /oauth2/register {
    proxy_pass http://127.0.0.1:4446;
}

location ~ ^/oauth2/register/[^/]+$ {
    proxy_pass http://127.0.0.1:4446;
}
```

## How It Works

### Request Flow

1. **Client sends DCR request** → Proxy
2. **Proxy forwards** → Ory Hydra
3. **Ory Hydra responds** with client data:
   ```json
   {
     "client_id": "xxx",
     "client_uri": "",         // Empty string
     "contacts": [],           // Empty array
     "logo_uri": ""            // Empty string
   }
   ```
4. **Proxy cleans response** by removing empty fields:
   ```json
   {
     "client_id": "xxx"
     // Empty fields removed
   }
   ```
5. **Client receives clean response** → No validation errors ✅

### Cleaning Logic

A field is removed if it's:
- `null` or `undefined`
- Empty string `""`
- Empty array `[]`

## API Endpoints

### POST /oauth2/register

Proxies Dynamic Client Registration requests to Ory Hydra and cleans the response.

**Request:**
```bash
curl -X POST http://localhost:4446/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My App",
    "redirect_uris": ["https://app.example.com/callback"]
  }'
```

**Response:** Client metadata with empty fields removed

### GET /oauth2/register/:clientId

Retrieves client registration data and cleans the response.

**Request:**
```bash
curl http://localhost:4446/oauth2/register/abc-123 \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response:** Client metadata with empty fields removed

### GET /health

Health check endpoint.

```bash
curl http://localhost:4446/health
```

**Response:**
```json
{
  "status": "ok",
  "service": "dcr-cleaner",
  "version": "1.0.0",
  "hydraUrl": "http://127.0.0.1:4444"
}
```

## Development

### Prerequisites

- Node.js >= 18.0.0
- npm or yarn

### Setup

```bash
# Install dependencies
npm install

# Run in development mode
npm run dev

# Build for production
npm run build

# Run tests (coming soon)
npm test
```

### Project Structure

```
oauth-dcr-cleaner/
├── src/
│   ├── index.ts       # Express server & endpoints
│   ├── cleaner.ts     # Field cleaning logic
│   ├── config.ts      # Configuration loader
│   └── logger.ts      # Winston logger setup
├── docs/              # Additional documentation
├── Dockerfile         # Docker image
├── docker-compose.yml # Docker Compose config
├── package.json       # npm package config
├── tsconfig.json      # TypeScript config
└── README.md          # This file
```

## Deployment

### Step-by-step Production Setup

**1. Install on server:**
```bash
cd /opt
git clone https://github.com/SerpstatGlobal/oauth-dcr-cleaner.git
cd oauth-dcr-cleaner
npm install
npm run build
```

**2. Configure (edit ecosystem.config.js):**
```javascript
env: {
  NODE_ENV: 'production',
  PROXY_PORT: 4446,
  HYDRA_URL: 'http://127.0.0.1:4444',  // Your Ory Hydra URL
  FIELDS_TO_CLEAN: 'client_uri,logo_uri,tos_uri,policy_uri,contacts',
  LOG_LEVEL: 'info',
}
```

**3. Start with PM2:**
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup  # Enable auto-start on reboot
```

**4. Configure nginx to proxy DCR requests:**
```nginx
# Proxy DCR endpoints through the cleaner
location = /oauth2/register {
    proxy_pass http://127.0.0.1:4446;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

location ~ ^/oauth2/register/[^/]+$ {
    proxy_pass http://127.0.0.1:4446;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

# Other OAuth endpoints go directly to Hydra
location /oauth2/ {
    proxy_pass http://127.0.0.1:4444;
}
```

**5. Reload nginx:**
```bash
nginx -t && systemctl reload nginx
```

**6. Test:**
```bash
curl http://localhost:4446/health
```

## Troubleshooting

### Proxy not cleaning fields

**Check configuration:**
```bash
curl http://localhost:4446/health
```

**Enable debug logging:**
```bash
export LOG_LEVEL=debug
npm start
```

### Ory Hydra connection refused

**Check HYDRA_URL:**
- For local development: `http://127.0.0.1:4444`
- For Docker: `http://host.docker.internal:4444`
- For production: Use internal Docker network or actual hostname

### Claude Code still fails

Make sure your OAuth discovery points to the proxy:

```bash
curl https://your-server.com/.well-known/oauth-authorization-server | grep registration_endpoint
# Should return: https://your-server.com:4446/oauth2/register
```

## Contributing

Contributions are welcome! Please:

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request

## License

MIT License - see [LICENSE](LICENSE) file for details

## Related Projects

- [Ory Hydra](https://github.com/ory/hydra) - OAuth 2.0 and OpenID Connect server
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification

## Support

- 🐛 [Report a bug](https://github.com/SerpstatGlobal/oauth-dcr-cleaner/issues)
- 💡 [Request a feature](https://github.com/SerpstatGlobal/oauth-dcr-cleaner/issues)
- 📧 Email: support@serpstat.com

## Authors

- **Serpstat** - [GitHub](https://github.com/SerpstatGlobal) - [Website](https://serpstat.com)

---

Made by Serpstat with ❤️ to fix Claude + Ory Hydra integration